Skip to main content

semantic_memory/
db.rs

1//! Database initialization, migrations, integrity checks, and durable sidecar state.
2
3use crate::config::{EmbeddingConfig, MemoryLimits, PoolConfig};
4use crate::embedder::SparseWeights;
5use crate::error::MemoryError;
6use crate::quantize::unpack_quantized;
7#[cfg(feature = "turbo-quant-codec")]
8use crate::types::{DerivedVectorArtifactGenerationV1, VectorArtifactBuildReceiptV1};
9use crate::types::{
10    EpisodeOutcome, ProveKvPoolArtifactStatusV1, ProveKvPoolGenerationStatus,
11    ProveKvPoolGenerationV1, ProveKvPoolItemMapEntryV1, Role, SearchSourceType,
12    SparseRankReceiptV1, VectorSearchReceiptV1, VerificationStatus,
13};
14use chrono::{DateTime, Utc};
15use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use stack_ids::ContentDigest;
18#[cfg(feature = "turbo-quant-codec")]
19use stack_ids::DigestBuilder;
20use std::path::Path;
21
22/// V1 migration: full schema.
23const MIGRATION_V1: &str = r#"
24-- CONVERSATIONS
25CREATE TABLE sessions (
26    id          TEXT PRIMARY KEY,
27    channel     TEXT NOT NULL DEFAULT 'repl',
28    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
29    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
30    metadata    TEXT
31);
32
33CREATE INDEX idx_sessions_updated ON sessions(updated_at DESC);
34
35CREATE TABLE messages (
36    id          INTEGER PRIMARY KEY AUTOINCREMENT,
37    session_id  TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
38    role        TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'tool')),
39    content     TEXT NOT NULL,
40    token_count INTEGER,
41    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
42    metadata    TEXT
43);
44
45CREATE INDEX idx_messages_session ON messages(session_id, created_at ASC);
46CREATE INDEX idx_messages_created ON messages(created_at DESC);
47
48-- KNOWLEDGE (Facts)
49CREATE TABLE facts (
50    id          TEXT PRIMARY KEY,
51    namespace   TEXT NOT NULL DEFAULT 'general',
52    content     TEXT NOT NULL,
53    source      TEXT,
54    embedding   BLOB,
55    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
56    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
57    metadata    TEXT
58);
59
60CREATE INDEX idx_facts_namespace ON facts(namespace);
61CREATE INDEX idx_facts_updated ON facts(updated_at DESC);
62
63CREATE TABLE facts_rowid_map (
64    rowid       INTEGER PRIMARY KEY AUTOINCREMENT,
65    fact_id     TEXT NOT NULL UNIQUE REFERENCES facts(id) ON DELETE CASCADE
66);
67
68CREATE VIRTUAL TABLE facts_fts USING fts5(
69    content,
70    content='',
71    content_rowid='rowid',
72    tokenize='porter unicode61'
73);
74
75-- DOCUMENTS (Chunked content)
76CREATE TABLE documents (
77    id          TEXT PRIMARY KEY,
78    title       TEXT NOT NULL,
79    source_path TEXT,
80    namespace   TEXT NOT NULL DEFAULT 'general',
81    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
82    metadata    TEXT
83);
84
85CREATE TABLE chunks (
86    id          TEXT PRIMARY KEY,
87    document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
88    chunk_index INTEGER NOT NULL,
89    content     TEXT NOT NULL,
90    token_count INTEGER,
91    embedding   BLOB,
92    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
93);
94
95CREATE INDEX idx_chunks_document ON chunks(document_id, chunk_index ASC);
96
97CREATE TABLE chunks_rowid_map (
98    rowid       INTEGER PRIMARY KEY AUTOINCREMENT,
99    chunk_id    TEXT NOT NULL UNIQUE REFERENCES chunks(id) ON DELETE CASCADE
100);
101
102CREATE VIRTUAL TABLE chunks_fts USING fts5(
103    content,
104    content='',
105    content_rowid='rowid',
106    tokenize='porter unicode61'
107);
108
109-- EMBEDDING METADATA
110CREATE TABLE embedding_metadata (
111    id          INTEGER PRIMARY KEY CHECK (id = 1),
112    model_name  TEXT NOT NULL,
113    dimensions  INTEGER NOT NULL,
114    updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
115);
116"#;
117
118/// V2 migration: message embeddings for conversation search.
119const MIGRATION_V2: &str = r#"
120ALTER TABLE messages ADD COLUMN embedding BLOB;
121
122CREATE TABLE messages_rowid_map (
123    rowid       INTEGER PRIMARY KEY AUTOINCREMENT,
124    message_id  INTEGER NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE
125);
126
127CREATE VIRTUAL TABLE messages_fts USING fts5(
128    content,
129    content='',
130    content_rowid='rowid',
131    tokenize='porter unicode61'
132);
133"#;
134
135/// V3 migration: embedding staleness tracking.
136const MIGRATION_V3: &str = r#"
137ALTER TABLE embedding_metadata ADD COLUMN embeddings_dirty INTEGER NOT NULL DEFAULT 0;
138"#;
139
140/// V4 migration: HNSW metadata tracking.
141const MIGRATION_V4: &str = r#"
142CREATE TABLE IF NOT EXISTS hnsw_metadata (
143    key TEXT PRIMARY KEY,
144    value TEXT NOT NULL
145);
146"#;
147
148/// V5 migration: quantized embeddings + HNSW keymap persistence.
149const MIGRATION_V5: &str = r#"
150ALTER TABLE facts ADD COLUMN embedding_q8 BLOB;
151ALTER TABLE chunks ADD COLUMN embedding_q8 BLOB;
152ALTER TABLE messages ADD COLUMN embedding_q8 BLOB;
153
154CREATE TABLE IF NOT EXISTS hnsw_keymap (
155    node_id     INTEGER PRIMARY KEY,
156    item_key    TEXT NOT NULL UNIQUE,
157    deleted     INTEGER NOT NULL DEFAULT 0
158);
159
160CREATE INDEX idx_hnsw_keymap_key ON hnsw_keymap(item_key);
161"#;
162
163/// V6 migration: episodes table for causal tracking.
164const MIGRATION_V6: &str = r#"
165CREATE TABLE IF NOT EXISTS episodes (
166    document_id TEXT PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
167    cause_ids TEXT NOT NULL,
168    effect_type TEXT NOT NULL,
169    outcome TEXT NOT NULL DEFAULT 'pending',
170    confidence REAL NOT NULL DEFAULT 0.0,
171    verification_status TEXT NOT NULL DEFAULT '{"status":"unverified"}',
172    experiment_id TEXT,
173    created_at TEXT NOT NULL DEFAULT (datetime('now'))
174);
175
176CREATE INDEX IF NOT EXISTS idx_episodes_effect_type ON episodes(effect_type);
177CREATE INDEX IF NOT EXISTS idx_episodes_outcome ON episodes(outcome);
178CREATE INDEX IF NOT EXISTS idx_episodes_experiment_id ON episodes(experiment_id);
179"#;
180
181/// V7 migration: searchable episodes + durable sidecar journal.
182const MIGRATION_V7: &str = r#"
183ALTER TABLE episodes ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now'));
184ALTER TABLE episodes ADD COLUMN search_text TEXT NOT NULL DEFAULT '';
185ALTER TABLE episodes ADD COLUMN embedding BLOB;
186ALTER TABLE episodes ADD COLUMN embedding_q8 BLOB;
187
188CREATE TABLE IF NOT EXISTS episodes_rowid_map (
189    rowid       INTEGER PRIMARY KEY AUTOINCREMENT,
190    document_id TEXT NOT NULL UNIQUE REFERENCES episodes(document_id) ON DELETE CASCADE
191);
192
193CREATE VIRTUAL TABLE episodes_fts USING fts5(
194    content,
195    content='',
196    content_rowid='rowid',
197    tokenize='porter unicode61'
198);
199
200CREATE TABLE IF NOT EXISTS pending_index_ops (
201    item_key      TEXT PRIMARY KEY,
202    entity_type   TEXT NOT NULL,
203    op_kind       TEXT NOT NULL CHECK (op_kind IN ('upsert', 'delete')),
204    attempt_count INTEGER NOT NULL DEFAULT 0,
205    last_error    TEXT,
206    updated_at    TEXT NOT NULL DEFAULT (datetime('now'))
207);
208
209INSERT OR IGNORE INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '0');
210
211UPDATE episodes
212SET search_text = TRIM(
213    COALESCE(effect_type, '') || ' ' ||
214    COALESCE(outcome, '') || ' ' ||
215    COALESCE(experiment_id, '') || ' ' ||
216    COALESCE(cause_ids, '')
217)
218WHERE search_text = '';
219
220INSERT OR IGNORE INTO episodes_rowid_map (document_id)
221SELECT document_id FROM episodes;
222
223INSERT INTO episodes_fts (rowid, content)
224SELECT rm.rowid, e.search_text
225FROM episodes_rowid_map rm
226JOIN episodes e ON e.document_id = rm.document_id;
227"#;
228
229/// V8 migration: durable episode trace IDs.
230const MIGRATION_V8: &str = r#"
231ALTER TABLE episodes ADD COLUMN trace_id TEXT;
232"#;
233
234/// V9 migration: first-class episode identity + normalized causal edge table.
235///
236/// Rebuilds the episodes table so `episode_id` is the primary key while
237/// `document_id` becomes a non-unique FK allowing multiple episodes per doc.
238/// Adds `episode_causes` for normalized causal backlinks.
239///
240/// Applied via `run_migration_v9()` because it requires table rebuild.
241const MIGRATION_V9: &str = "";
242
243/// V18 migration: durable, replay-addressable search receipts.
244const MIGRATION_V18: &str = r#"
245CREATE TABLE IF NOT EXISTS search_receipts (
246    receipt_id             TEXT PRIMARY KEY,
247    schema_version         TEXT NOT NULL,
248    evaluation_time        TEXT NOT NULL,
249    search_profile         TEXT NOT NULL,
250    candidate_backend      TEXT NOT NULL,
251    approximate            INTEGER NOT NULL CHECK (approximate IN (0, 1)),
252    exact_rerank           INTEGER NOT NULL CHECK (exact_rerank IN (0, 1)),
253    fallback               TEXT,
254    requested_candidates   INTEGER NOT NULL CHECK (requested_candidates >= 0),
255    returned_candidates    INTEGER NOT NULL CHECK (returned_candidates >= 0),
256    post_filter_candidates INTEGER NOT NULL CHECK (post_filter_candidates >= 0),
257    result_ids_json        TEXT NOT NULL,
258    receipt_json           TEXT NOT NULL,
259    receipt_digest         TEXT NOT NULL,
260    created_at             TEXT NOT NULL DEFAULT (datetime('now'))
261);
262
263CREATE INDEX IF NOT EXISTS idx_search_receipts_created
264ON search_receipts(created_at DESC);
265
266CREATE INDEX IF NOT EXISTS idx_search_receipts_backend
267ON search_receipts(candidate_backend);
268"#;
269
270/// V19 migration: rebuildable derived vector acceleration artifacts.
271const MIGRATION_V19: &str = r#"
272CREATE TABLE IF NOT EXISTS derived_vector_artifacts (
273    item_key                TEXT NOT NULL,
274    codec_family            TEXT NOT NULL,
275    codec_profile_digest    TEXT NOT NULL,
276    source_embedding_digest TEXT NOT NULL,
277    encoded_digest          TEXT NOT NULL,
278    artifact_digest         TEXT NOT NULL,
279    encoding                TEXT NOT NULL,
280    dim                     INTEGER NOT NULL,
281    encoded                 BLOB NOT NULL,
282    created_at              TEXT NOT NULL DEFAULT (datetime('now')),
283    status                  TEXT NOT NULL DEFAULT 'active',
284    PRIMARY KEY (item_key, codec_family, codec_profile_digest)
285);
286
287CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
288ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
289
290CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
291ON derived_vector_artifacts(source_embedding_digest);
292"#;
293
294/// V20 migration: align derived vector artifact rows with P31 evidence fields.
295const MIGRATION_V20: &str = r#"
296-- Procedural migration; see run_migration_v20.
297"#;
298
299/// V21 migration: generation-level manifests for derived vector artifacts.
300const MIGRATION_V21: &str = r#"
301CREATE TABLE IF NOT EXISTS derived_vector_artifact_generations (
302    generation_id            TEXT PRIMARY KEY,
303    schema_version           TEXT NOT NULL,
304    codec_family             TEXT NOT NULL,
305    codec_profile_digest     TEXT NOT NULL,
306    source_snapshot_digest   TEXT NOT NULL,
307    source_row_count         INTEGER NOT NULL,
308    artifact_count           INTEGER NOT NULL,
309    source_tables_json       TEXT NOT NULL,
310    dim                      INTEGER NOT NULL,
311    encoding                 TEXT NOT NULL,
312    created_at               TEXT NOT NULL,
313    build_receipt_id         TEXT,
314    artifact_manifest_digest TEXT NOT NULL,
315    status                   TEXT NOT NULL CHECK (status IN ('active', 'superseded', 'invalidated', 'failed')),
316    degradations_json        TEXT NOT NULL DEFAULT '[]'
317);
318
319CREATE INDEX IF NOT EXISTS idx_derived_vector_generations_profile
320ON derived_vector_artifact_generations(codec_family, codec_profile_digest, status, created_at DESC);
321"#;
322
323/// V23 migration: codec governance columns on derived_vector_artifacts.
324/// Tracks governed compression pipeline metadata for turbo-quant-codec integration.
325const MIGRATION_V23: &str = r#"
326ALTER TABLE derived_vector_artifacts ADD COLUMN codec_governance_receipt_id TEXT;
327ALTER TABLE derived_vector_artifacts ADD COLUMN codec_profile TEXT;
328ALTER TABLE derived_vector_artifacts ADD COLUMN degradation_budget REAL;
329ALTER TABLE derived_vector_artifacts ADD COLUMN raw_source_artifact_id TEXT;
330"#;
331
332/// V22 migration: bitemporal columns on episodes table.
333/// Adds valid_time, recorded_time, superseded_by, and fact_digest for append-supersede semantics.
334const MIGRATION_V22: &str = r#"
335ALTER TABLE episodes ADD COLUMN valid_time TEXT;
336ALTER TABLE episodes ADD COLUMN recorded_time TEXT NOT NULL DEFAULT (datetime('now'));
337ALTER TABLE episodes ADD COLUMN superseded_by TEXT;
338ALTER TABLE episodes ADD COLUMN fact_digest TEXT;
339CREATE INDEX IF NOT EXISTS idx_episodes_recorded ON episodes(recorded_time ASC);
340CREATE INDEX IF NOT EXISTS idx_episodes_valid ON episodes(valid_time);
341CREATE INDEX IF NOT EXISTS idx_episodes_superseded ON episodes(superseded_by) WHERE superseded_by IS NOT NULL;
342UPDATE episodes SET recorded_time = updated_at WHERE recorded_time IS NULL OR recorded_time = '';
343"#;
344
345/// V24 migration: proveKV/poly-kv generation-level derived candidate pool metadata.
346const MIGRATION_V24: &str = r#"
347CREATE TABLE IF NOT EXISTS provekv_pool_generations (
348  generation_id TEXT PRIMARY KEY,
349  embedding_snapshot_digest TEXT NOT NULL,
350  source_digest TEXT NOT NULL,
351  pool_manifest_digest TEXT NOT NULL,
352  codec_family TEXT NOT NULL,
353  codec_profile TEXT NOT NULL,
354  vector_dim INTEGER NOT NULL,
355  item_count INTEGER NOT NULL,
356  payload_bytes INTEGER NOT NULL,
357  payload BLOB NOT NULL,
358  status TEXT NOT NULL,
359  failure_reason TEXT,
360  created_at TEXT NOT NULL
361);
362
363CREATE TABLE IF NOT EXISTS provekv_pool_item_map (
364  generation_id TEXT NOT NULL,
365  item_id TEXT NOT NULL,
366  source_type TEXT NOT NULL,
367  pool_index INTEGER NOT NULL,
368  embedding_digest TEXT NOT NULL,
369  PRIMARY KEY (generation_id, item_id),
370  FOREIGN KEY (generation_id) REFERENCES provekv_pool_generations(generation_id) ON DELETE CASCADE
371);
372
373CREATE INDEX IF NOT EXISTS idx_provekv_pool_item_map_generation_index
374  ON provekv_pool_item_map(generation_id, pool_index);
375
376CREATE INDEX IF NOT EXISTS idx_provekv_pool_generations_status_created
377  ON provekv_pool_generations(status, created_at DESC);
378"#;
379
380/// V25 migration: semiring provenance table (Phase 2).
381///
382/// Idempotent. The `provenance` table is append-only truth-bearing state keyed
383/// by (item_type, item_id). The Rust API is feature-gated behind `provenance`,
384/// but the table is always created so the schema version sequence stays
385/// monotonic.
386const MIGRATION_V25: &str = r#"
387CREATE TABLE IF NOT EXISTS provenance (
388    id                 TEXT PRIMARY KEY,
389    item_type          TEXT NOT NULL,
390    item_id            TEXT NOT NULL,
391    semiring_type      TEXT NOT NULL,
392    semiring_value     TEXT NOT NULL,
393    support_chain_json TEXT NOT NULL DEFAULT '[]',
394    recorded_at        TEXT NOT NULL DEFAULT (datetime('now')),
395    episode_id         TEXT
396);
397
398CREATE INDEX IF NOT EXISTS idx_provenance_item
399    ON provenance(item_type, item_id);
400
401CREATE INDEX IF NOT EXISTS idx_provenance_episode
402    ON provenance(episode_id);
403"#;
404
405/// V26 migration: temporal weight columns (Phase 3).
406///
407/// Procedural migration — SQLite ALTER TABLE ADD COLUMN does not support
408/// IF NOT EXISTS, so we use `add_column_if_missing` for idempotency.
409/// `temporal_weight` is a COMPUTED SCORE (not truth) — the only column
410/// callers may UPDATE directly. The Rust API is feature-gated behind `temporal`.
411const MIGRATION_V26: &str = "";
412
413/// Run V26 migration procedurally: add temporal_weight columns if absent.
414fn run_migration_v26(conn: &Connection) -> Result<(), rusqlite::Error> {
415    add_column_if_missing(
416        conn,
417        "facts",
418        "temporal_weight",
419        "REAL NOT NULL DEFAULT 1.0",
420    )?;
421    add_column_if_missing(
422        conn,
423        "chunks",
424        "temporal_weight",
425        "REAL NOT NULL DEFAULT 1.0",
426    )?;
427    add_column_if_missing(
428        conn,
429        "messages",
430        "temporal_weight",
431        "REAL NOT NULL DEFAULT 1.0",
432    )?;
433    conn.execute_batch(
434        "CREATE INDEX IF NOT EXISTS idx_facts_temporal ON facts(temporal_weight);
435         CREATE INDEX IF NOT EXISTS idx_chunks_temporal ON chunks(temporal_weight);",
436    )?;
437    Ok(())
438}
439
440/// V27 migration: first-class stored graph edges table.
441///
442/// Idempotent. Stores durable, typed relationships between any two nodes.
443/// Append-only with invalidation (is_invalidated flag). Content digest
444/// (blake3) ensures idempotent insertion.
445const MIGRATION_V27: &str = r#"
446CREATE TABLE IF NOT EXISTS graph_edges (
447    id                  TEXT PRIMARY KEY,
448    source              TEXT NOT NULL,
449    target              TEXT NOT NULL,
450    edge_type           TEXT NOT NULL,
451    weight              REAL NOT NULL,
452    metadata            TEXT,
453    content_digest      TEXT NOT NULL,
454    recorded_at         TEXT NOT NULL,
455    is_invalidated      INTEGER NOT NULL DEFAULT 0,
456    invalidated_at      TEXT,
457    invalidation_reason TEXT
458);
459
460CREATE INDEX IF NOT EXISTS idx_graph_edges_source
461    ON graph_edges(source) WHERE is_invalidated = 0;
462
463CREATE INDEX IF NOT EXISTS idx_graph_edges_target
464    ON graph_edges(target) WHERE is_invalidated = 0;
465
466CREATE INDEX IF NOT EXISTS idx_graph_edges_digest
467    ON graph_edges(content_digest) WHERE is_invalidated = 0;
468"#;
469
470/// V28 migration: bitemporal semantics for first-class graph edges.
471///
472/// `valid_time` is domain/business time. `recorded_time` is system knowledge
473/// time. Existing V27 edges are backfilled from `recorded_at`, preserving the
474/// original append-only insertion timestamp as their initial bitemporal point.
475const MIGRATION_V28: &str = "";
476
477fn run_migration_v28(conn: &Connection) -> Result<(), rusqlite::Error> {
478    add_column_if_missing(conn, "graph_edges", "valid_time", "TEXT")?;
479    add_column_if_missing(conn, "graph_edges", "recorded_time", "TEXT")?;
480    // Backfill missing bitemporal timestamps with recorded_at.
481    conn.execute(
482        "UPDATE graph_edges
483         SET valid_time = COALESCE(valid_time, recorded_at),
484             recorded_time = COALESCE(recorded_time, recorded_at)",
485        [],
486    )?;
487    // Canonicalize any RFC3339 values to fixed-width SQL microseconds so
488    // lexicographic ordering equals chronological ordering. Mixed formats
489    // can otherwise make as-of queries wrong.
490    conn.execute(
491        "UPDATE graph_edges
492         SET valid_time = format('%Y-%m-%d %H:%M:%f', valid_time),
493             recorded_time = format('%Y-%m-%d %H:%M:%f', recorded_time),
494             invalidated_at = CASE
495                 WHEN invalidated_at IS NULL THEN NULL
496                 ELSE format('%Y-%m-%d %H:%M:%f', invalidated_at)
497             END",
498        [],
499    )?;
500    conn.execute_batch(
501        "CREATE INDEX IF NOT EXISTS idx_graph_edges_bitemporal
502             ON graph_edges(valid_time, recorded_time);
503         CREATE INDEX IF NOT EXISTS idx_graph_edges_recorded_time
504             ON graph_edges(recorded_time);",
505    )?;
506    Ok(())
507}
508
509/// V29 migration: transactional authority state, lineage heads, operation journal, and receipts.
510const MIGRATION_V29: &str = r#"
511CREATE TABLE IF NOT EXISTS authority_state (
512    id              INTEGER PRIMARY KEY CHECK (id = 1),
513    retrieval_epoch INTEGER NOT NULL CHECK (retrieval_epoch >= 0)
514);
515INSERT OR IGNORE INTO authority_state (id, retrieval_epoch) VALUES (1, 0);
516
517CREATE TABLE IF NOT EXISTS authority_lineages (
518    lineage_id      TEXT PRIMARY KEY,
519    active_head_id  TEXT NOT NULL REFERENCES facts(id),
520    updated_epoch   INTEGER NOT NULL CHECK (updated_epoch >= 0)
521);
522
523CREATE TABLE IF NOT EXISTS authority_versions (
524    fact_id         TEXT PRIMARY KEY REFERENCES facts(id) ON DELETE CASCADE,
525    lineage_id      TEXT NOT NULL REFERENCES authority_lineages(lineage_id),
526    version         INTEGER NOT NULL CHECK (version > 0),
527    operation_kind  TEXT NOT NULL CHECK (operation_kind IN ('append', 'supersede', 'redact')),
528    is_active       INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
529    is_redacted     INTEGER NOT NULL DEFAULT 0 CHECK (is_redacted IN (0, 1)),
530    content_digest  TEXT NOT NULL,
531    UNIQUE (lineage_id, version)
532);
533CREATE UNIQUE INDEX IF NOT EXISTS idx_authority_one_active_head
534    ON authority_versions(lineage_id) WHERE is_active = 1;
535CREATE INDEX IF NOT EXISTS idx_authority_versions_lineage
536    ON authority_versions(lineage_id, version);
537
538CREATE TABLE IF NOT EXISTS operation_journal (
539    operation_id            TEXT PRIMARY KEY,
540    caller_idempotency_key  TEXT NOT NULL UNIQUE,
541    operation_kind          TEXT NOT NULL,
542    payload_digest          TEXT NOT NULL,
543    principal               TEXT NOT NULL,
544    caller_id               TEXT NOT NULL,
545    before_epoch            INTEGER NOT NULL,
546    after_epoch             INTEGER NOT NULL,
547    affected_ids_json       TEXT NOT NULL,
548    content_digest          TEXT NOT NULL,
549    committed_at            TEXT NOT NULL
550);
551CREATE INDEX IF NOT EXISTS idx_operation_journal_idempotency
552    ON operation_journal(caller_idempotency_key);
553
554CREATE TABLE IF NOT EXISTS authority_receipts (
555    receipt_id              TEXT PRIMARY KEY,
556    operation_id            TEXT NOT NULL UNIQUE REFERENCES operation_journal(operation_id),
557    caller_idempotency_key  TEXT NOT NULL UNIQUE,
558    receipt_json             TEXT NOT NULL,
559    receipt_digest           TEXT NOT NULL,
560    created_at               TEXT NOT NULL
561);
562CREATE INDEX IF NOT EXISTS idx_authority_receipts_operation
563    ON authority_receipts(operation_id);
564CREATE INDEX IF NOT EXISTS idx_authority_receipts_idempotency
565    ON authority_receipts(caller_idempotency_key);
566"#;
567
568/// V30 migration: immutable verification/quarantine records adjacent to authority state.
569const MIGRATION_V30: &str = r#"
570CREATE TABLE IF NOT EXISTS memory_transition_records (
571    record_id               TEXT PRIMARY KEY,
572    caller_idempotency_key  TEXT NOT NULL UNIQUE,
573    principal               TEXT NOT NULL,
574    caller_id               TEXT NOT NULL,
575    candidate_digest        TEXT NOT NULL,
576    candidate_json          TEXT NOT NULL,
577    verification_json       TEXT NOT NULL,
578    disposition             TEXT NOT NULL CHECK (disposition IN ('commit', 'quarantine')),
579    authority_receipt_id    TEXT REFERENCES authority_receipts(receipt_id),
580    created_at              TEXT NOT NULL
581);
582CREATE INDEX IF NOT EXISTS idx_memory_transition_disposition
583    ON memory_transition_records(disposition, created_at);
584CREATE TRIGGER IF NOT EXISTS memory_transition_records_no_update
585BEFORE UPDATE ON memory_transition_records
586BEGIN
587    SELECT RAISE(ABORT, 'memory transition records are immutable');
588END;
589CREATE TRIGGER IF NOT EXISTS memory_transition_records_no_delete
590BEFORE DELETE ON memory_transition_records
591BEGIN
592    SELECT RAISE(ABORT, 'memory transition records are immutable');
593END;
594"#;
595
596/// V31 migration: immutable write-time origin labels and append-only revocations.
597const MIGRATION_V31: &str = r#"
598CREATE TABLE IF NOT EXISTS origin_authority_labels (
599    fact_id          TEXT PRIMARY KEY REFERENCES facts(id) ON DELETE CASCADE,
600    label_json       TEXT NOT NULL,
601    label_digest     TEXT NOT NULL,
602    recorded_at      TEXT NOT NULL
603);
604CREATE TRIGGER IF NOT EXISTS origin_authority_labels_no_update
605BEFORE UPDATE ON origin_authority_labels
606BEGIN
607    SELECT RAISE(ABORT, 'origin authority labels are immutable');
608END;
609CREATE TRIGGER IF NOT EXISTS origin_authority_labels_no_delete
610BEFORE DELETE ON origin_authority_labels
611BEGIN
612    SELECT RAISE(ABORT, 'origin authority labels are immutable');
613END;
614
615CREATE TABLE IF NOT EXISTS origin_authority_revocations (
616    revocation_id           TEXT PRIMARY KEY,
617    fact_id                 TEXT NOT NULL REFERENCES facts(id),
618    caller_idempotency_key  TEXT NOT NULL UNIQUE,
619    principal               TEXT NOT NULL,
620    revocation_reference    TEXT NOT NULL,
621    revoked_at              TEXT NOT NULL
622);
623CREATE INDEX IF NOT EXISTS idx_origin_authority_revocations_fact
624    ON origin_authority_revocations(fact_id, revoked_at);
625CREATE TRIGGER IF NOT EXISTS origin_authority_revocations_no_update
626BEFORE UPDATE ON origin_authority_revocations
627BEGIN
628    SELECT RAISE(ABORT, 'origin authority revocations are append-only');
629END;
630CREATE TRIGGER IF NOT EXISTS origin_authority_revocations_no_delete
631BEFORE DELETE ON origin_authority_revocations
632BEGIN
633    SELECT RAISE(ABORT, 'origin authority revocations are append-only');
634END;
635"#;
636
637/// V32 migration: append-only selective-forgetting tombstones and closure receipts.
638const MIGRATION_V32: &str = r#"
639ALTER TABLE authority_state ADD COLUMN projection_epoch INTEGER NOT NULL DEFAULT 0;
640ALTER TABLE authority_state ADD COLUMN cache_epoch INTEGER NOT NULL DEFAULT 0;
641ALTER TABLE authority_state ADD COLUMN export_epoch INTEGER NOT NULL DEFAULT 0;
642ALTER TABLE authority_state ADD COLUMN replay_epoch INTEGER NOT NULL DEFAULT 0;
643
644CREATE TABLE IF NOT EXISTS forgotten_facts (
645    fact_id          TEXT PRIMARY KEY REFERENCES facts(id),
646    receipt_id       TEXT NOT NULL,
647    namespace        TEXT NOT NULL,
648    content_digest   TEXT NOT NULL,
649    forgotten_at     TEXT NOT NULL
650);
651CREATE INDEX IF NOT EXISTS idx_forgotten_facts_namespace
652    ON forgotten_facts(namespace, forgotten_at);
653CREATE TRIGGER IF NOT EXISTS forgotten_facts_no_update
654BEFORE UPDATE ON forgotten_facts BEGIN
655    SELECT RAISE(ABORT, 'forgotten fact tombstones are append-only');
656END;
657CREATE TRIGGER IF NOT EXISTS forgotten_facts_no_delete
658BEFORE DELETE ON forgotten_facts BEGIN
659    SELECT RAISE(ABORT, 'forgotten fact tombstones are append-only');
660END;
661
662CREATE TABLE IF NOT EXISTS forgetting_artifact_invalidations (
663    surface_kind    TEXT NOT NULL,
664    artifact_id     TEXT NOT NULL,
665    receipt_id      TEXT NOT NULL,
666    invalidated_at  TEXT NOT NULL,
667    PRIMARY KEY(surface_kind, artifact_id)
668);
669CREATE INDEX IF NOT EXISTS idx_forgetting_invalidations_receipt
670    ON forgetting_artifact_invalidations(receipt_id);
671CREATE TRIGGER IF NOT EXISTS forgetting_invalidations_no_update
672BEFORE UPDATE ON forgetting_artifact_invalidations BEGIN
673    SELECT RAISE(ABORT, 'forgetting invalidations are append-only');
674END;
675CREATE TRIGGER IF NOT EXISTS forgetting_invalidations_no_delete
676BEFORE DELETE ON forgetting_artifact_invalidations BEGIN
677    SELECT RAISE(ABORT, 'forgetting invalidations are append-only');
678END;
679
680CREATE TABLE IF NOT EXISTS forgetting_closure_receipts (
681    receipt_id              TEXT PRIMARY KEY,
682    caller_idempotency_key  TEXT NOT NULL UNIQUE,
683    payload_digest          TEXT NOT NULL,
684    receipt_json            TEXT NOT NULL,
685    receipt_digest          TEXT NOT NULL,
686    created_at              TEXT NOT NULL
687);
688CREATE TRIGGER IF NOT EXISTS forgetting_receipts_no_update
689BEFORE UPDATE ON forgetting_closure_receipts BEGIN
690    SELECT RAISE(ABORT, 'forgetting receipts are immutable');
691END;
692CREATE TRIGGER IF NOT EXISTS forgetting_receipts_no_delete
693BEFORE DELETE ON forgetting_closure_receipts BEGIN
694    SELECT RAISE(ABORT, 'forgetting receipts are immutable');
695END;
696"#;
697
698/// V33 migration: append-only shadow policy proposals, versions, and promotion receipts.
699const MIGRATION_V33: &str = r#"
700CREATE TABLE IF NOT EXISTS shadow_policy_proposals (
701    proposal_id       TEXT PRIMARY KEY,
702    idempotency_key   TEXT NOT NULL UNIQUE,
703    principal         TEXT NOT NULL,
704    policy_kind       TEXT NOT NULL,
705    proposal_digest   TEXT NOT NULL,
706    proposal_json     TEXT NOT NULL,
707    status            TEXT NOT NULL,
708    created_at        TEXT NOT NULL
709);
710CREATE INDEX IF NOT EXISTS idx_shadow_policy_proposals_principal
711    ON shadow_policy_proposals(principal, policy_kind, created_at);
712CREATE TRIGGER IF NOT EXISTS shadow_policy_proposals_no_update
713BEFORE UPDATE ON shadow_policy_proposals BEGIN
714    SELECT RAISE(ABORT, 'shadow policy proposals are append-only');
715END;
716CREATE TRIGGER IF NOT EXISTS shadow_policy_proposals_no_delete
717BEFORE DELETE ON shadow_policy_proposals BEGIN
718    SELECT RAISE(ABORT, 'shadow policy proposals are append-only');
719END;
720
721CREATE TABLE IF NOT EXISTS shadow_policy_versions (
722    principal          TEXT NOT NULL,
723    policy_kind        TEXT NOT NULL,
724    version            INTEGER NOT NULL CHECK (version > 0),
725    policy_json        TEXT NOT NULL,
726    policy_digest      TEXT NOT NULL,
727    proposal_id        TEXT NOT NULL REFERENCES shadow_policy_proposals(proposal_id),
728    activated_by       TEXT NOT NULL,
729    activated_at       TEXT NOT NULL,
730    PRIMARY KEY (principal, policy_kind, version)
731);
732CREATE TRIGGER IF NOT EXISTS shadow_policy_versions_no_update
733BEFORE UPDATE ON shadow_policy_versions BEGIN
734    SELECT RAISE(ABORT, 'shadow policy versions are append-only');
735END;
736CREATE TRIGGER IF NOT EXISTS shadow_policy_versions_no_delete
737BEFORE DELETE ON shadow_policy_versions BEGIN
738    SELECT RAISE(ABORT, 'shadow policy versions are append-only');
739END;
740
741CREATE TABLE IF NOT EXISTS shadow_active_policies (
742    principal          TEXT NOT NULL,
743    policy_kind        TEXT NOT NULL,
744    version            INTEGER NOT NULL,
745    policy_json        TEXT NOT NULL,
746    policy_digest      TEXT NOT NULL,
747    source_proposal_id TEXT NOT NULL,
748    activated_by       TEXT NOT NULL,
749    activated_at       TEXT NOT NULL,
750    PRIMARY KEY (principal, policy_kind),
751    FOREIGN KEY (principal, policy_kind, version)
752        REFERENCES shadow_policy_versions(principal, policy_kind, version)
753);
754
755CREATE TABLE IF NOT EXISTS shadow_policy_receipts (
756    receipt_id              TEXT PRIMARY KEY,
757    caller_idempotency_key  TEXT NOT NULL UNIQUE,
758    proposal_id             TEXT NOT NULL,
759    principal               TEXT NOT NULL,
760    policy_kind             TEXT NOT NULL,
761    evidence_digest         TEXT NOT NULL,
762    status                  TEXT NOT NULL,
763    receipt_json            TEXT NOT NULL,
764    receipt_digest          TEXT NOT NULL,
765    created_at              TEXT NOT NULL
766);
767CREATE INDEX IF NOT EXISTS idx_shadow_policy_receipts_proposal
768    ON shadow_policy_receipts(proposal_id, created_at);
769CREATE TRIGGER IF NOT EXISTS shadow_policy_receipts_no_update
770BEFORE UPDATE ON shadow_policy_receipts BEGIN
771    SELECT RAISE(ABORT, 'shadow policy receipts are immutable');
772END;
773CREATE TRIGGER IF NOT EXISTS shadow_policy_receipts_no_delete
774BEFORE DELETE ON shadow_policy_receipts BEGIN
775    SELECT RAISE(ABORT, 'shadow policy receipts are immutable');
776END;
777"#;
778
779/// V34 migration: isolated, immutable procedural-memory artifacts and lifecycle receipts.
780///
781/// These tables deliberately have no FTS, vector, fact, claim, or authority-lineage bridge.
782/// Procedures can therefore only be reached through the governed procedural API.
783const MIGRATION_V34: &str = r#"
784CREATE TABLE IF NOT EXISTS procedural_memory_artifacts (
785    artifact_id        TEXT PRIMARY KEY,
786    principal          TEXT NOT NULL,
787    capability_domain  TEXT NOT NULL,
788    capability_name    TEXT NOT NULL,
789    action_kind        TEXT NOT NULL,
790    version            INTEGER NOT NULL CHECK (version > 0),
791    supersedes         TEXT,
792    artifact_digest    TEXT NOT NULL UNIQUE,
793    artifact_json      TEXT NOT NULL,
794    created_at         TEXT NOT NULL
795);
796CREATE INDEX IF NOT EXISTS idx_procedural_memory_lookup
797    ON procedural_memory_artifacts(principal, capability_domain, capability_name, action_kind, version DESC);
798CREATE TRIGGER IF NOT EXISTS procedural_memory_artifacts_no_update
799BEFORE UPDATE ON procedural_memory_artifacts BEGIN
800    SELECT RAISE(ABORT, 'procedural memory artifacts are immutable');
801END;
802CREATE TRIGGER IF NOT EXISTS procedural_memory_artifacts_no_delete
803BEFORE DELETE ON procedural_memory_artifacts BEGIN
804    SELECT RAISE(ABORT, 'procedural memory artifacts are immutable');
805END;
806
807CREATE TABLE IF NOT EXISTS procedural_memory_events (
808    event_id           TEXT PRIMARY KEY,
809    artifact_id        TEXT NOT NULL REFERENCES procedural_memory_artifacts(artifact_id),
810    disposition        TEXT NOT NULL,
811    reason_digest      TEXT NOT NULL,
812    test_receipt_json  TEXT,
813    prior_event_digest TEXT,
814    event_digest       TEXT NOT NULL UNIQUE,
815    created_at         TEXT NOT NULL
816);
817CREATE INDEX IF NOT EXISTS idx_procedural_memory_events_artifact
818    ON procedural_memory_events(artifact_id, created_at, event_id);
819CREATE TRIGGER IF NOT EXISTS procedural_memory_events_no_update
820BEFORE UPDATE ON procedural_memory_events BEGIN
821    SELECT RAISE(ABORT, 'procedural memory events are append-only');
822END;
823CREATE TRIGGER IF NOT EXISTS procedural_memory_events_no_delete
824BEFORE DELETE ON procedural_memory_events BEGIN
825    SELECT RAISE(ABORT, 'procedural memory events are append-only');
826END;
827
828CREATE TABLE IF NOT EXISTS procedural_memory_receipts (
829    receipt_id              TEXT PRIMARY KEY,
830    caller_idempotency_key  TEXT NOT NULL UNIQUE,
831    operation               TEXT NOT NULL,
832    payload_digest          TEXT NOT NULL,
833    artifact_id             TEXT NOT NULL,
834    receipt_json            TEXT NOT NULL,
835    receipt_digest          TEXT NOT NULL UNIQUE,
836    created_at              TEXT NOT NULL
837);
838CREATE INDEX IF NOT EXISTS idx_procedural_memory_receipts_artifact
839    ON procedural_memory_receipts(artifact_id, created_at);
840CREATE TRIGGER IF NOT EXISTS procedural_memory_receipts_no_update
841BEFORE UPDATE ON procedural_memory_receipts BEGIN
842    SELECT RAISE(ABORT, 'procedural memory receipts are immutable');
843END;
844CREATE TRIGGER IF NOT EXISTS procedural_memory_receipts_no_delete
845BEFORE DELETE ON procedural_memory_receipts BEGIN
846    SELECT RAISE(ABORT, 'procedural memory receipts are immutable');
847END;
848"#;
849
850/// V35 migration: opt-in, privacy-sensitive inputs for complete search replay.
851const MIGRATION_V35: &str = r#"
852CREATE TABLE IF NOT EXISTS replay_inputs (
853    receipt_id TEXT PRIMARY KEY,
854    query_text TEXT NOT NULL,
855    namespaces_json TEXT,
856    source_types_json TEXT,
857    stored_at TEXT NOT NULL DEFAULT (datetime('now'))
858);
859"#;
860
861/// V36 migration: durable sparse vectors keyed by canonical search item ID.
862const MIGRATION_V36: &str = r#"
863CREATE TABLE IF NOT EXISTS sparse_vectors (
864    item_key TEXT PRIMARY KEY,
865    entries_json TEXT NOT NULL,
866    representation TEXT NOT NULL,
867    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
868);
869
870CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_fact
871AFTER DELETE ON facts BEGIN
872    DELETE FROM sparse_vectors WHERE item_key = 'fact:' || OLD.id;
873END;
874
875CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_chunk
876AFTER DELETE ON chunks BEGIN
877    DELETE FROM sparse_vectors WHERE item_key = 'chunk:' || OLD.id;
878END;
879
880CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_message
881AFTER DELETE ON messages BEGIN
882    DELETE FROM sparse_vectors WHERE item_key = 'msg:' || OLD.id;
883END;
884
885CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_episode
886AFTER DELETE ON episodes BEGIN
887    DELETE FROM sparse_vectors WHERE item_key = 'episode:' || OLD.episode_id;
888END;
889"#;
890
891/// Ordered list of migrations.
892#[allow(deprecated)]
893const MIGRATIONS: &[(u32, &str)] = &[
894    (1, MIGRATION_V1),
895    (2, MIGRATION_V2),
896    (3, MIGRATION_V3),
897    (4, MIGRATION_V4),
898    (5, MIGRATION_V5),
899    (6, MIGRATION_V6),
900    (7, MIGRATION_V7),
901    (8, MIGRATION_V8),
902    (9, MIGRATION_V9),
903    (10, crate::projection_import::MIGRATION_V10),
904    (11, crate::projection_storage::MIGRATION_V11),
905    (12, crate::projection_storage::MIGRATION_V12),
906    (13, crate::projection_storage::MIGRATION_V13),
907    (14, crate::projection_storage::MIGRATION_V14),
908    (15, crate::projection_storage::MIGRATION_V15),
909    (16, crate::projection_storage::MIGRATION_V16),
910    (17, crate::projection_storage::MIGRATION_V17),
911    (18, MIGRATION_V18),
912    (19, MIGRATION_V19),
913    (20, MIGRATION_V20),
914    (21, MIGRATION_V21),
915    (22, MIGRATION_V22),
916    (23, MIGRATION_V23),
917    (24, MIGRATION_V24),
918    (25, MIGRATION_V25),
919    (26, MIGRATION_V26),
920    (27, MIGRATION_V27),
921    (28, MIGRATION_V28),
922    (29, MIGRATION_V29),
923    (30, MIGRATION_V30),
924    (31, MIGRATION_V31),
925    (32, MIGRATION_V32),
926    (33, MIGRATION_V33),
927    (34, MIGRATION_V34),
928    (35, MIGRATION_V35),
929    (36, MIGRATION_V36),
930    (37, crate::journal::MIGRATION_V37),
931    (38, crate::journal::MIGRATION_V38),
932    (39, crate::journal::MIGRATION_V39),
933];
934
935/// Maximum schema version this build supports.
936pub const MAX_SCHEMA_VERSION: u32 = 39;
937
938/// Procedural migration for V9: rebuild episodes table with episode_id PK.
939fn run_migration_v9(conn: &Connection) -> Result<(), MemoryError> {
940    // Check if episodes table exists (fresh DBs won't have it yet at V6)
941    let episodes_exist: bool = conn
942        .query_row(
943            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='episodes'",
944            [],
945            |row| row.get(0),
946        )
947        .map_err(|e| MemoryError::MigrationFailed {
948            version: 9,
949            reason: format!("existence check failed: {e}"),
950        })?;
951
952    if !episodes_exist {
953        // No episodes table to migrate; create the target schema directly
954        conn.execute_batch(
955            "CREATE TABLE IF NOT EXISTS episode_causes (
956                 episode_id    TEXT NOT NULL,
957                 cause_node_id TEXT NOT NULL,
958                 ordinal       INTEGER NOT NULL DEFAULT 0,
959                 PRIMARY KEY (episode_id, cause_node_id)
960             );
961             CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
962        )?;
963        return Ok(());
964    }
965
966    // Disable foreign keys for table rebuild
967    conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
968
969    conn.execute_batch(
970        "CREATE TABLE episodes_new (
971             episode_id  TEXT PRIMARY KEY,
972             document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
973             cause_ids   TEXT NOT NULL,
974             effect_type TEXT NOT NULL,
975             outcome     TEXT NOT NULL DEFAULT 'pending',
976             confidence  REAL NOT NULL DEFAULT 0.0,
977             verification_status TEXT NOT NULL DEFAULT '{\"status\":\"unverified\"}',
978             experiment_id TEXT,
979             created_at  TEXT NOT NULL DEFAULT (datetime('now')),
980             updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
981             search_text TEXT NOT NULL DEFAULT '',
982             embedding   BLOB,
983             embedding_q8 BLOB,
984             trace_id    TEXT
985         )",
986    )?;
987
988    // Migrate existing data with deterministic episode_id
989    conn.execute_batch(
990        "INSERT INTO episodes_new
991             (episode_id, document_id, cause_ids, effect_type, outcome, confidence,
992              verification_status, experiment_id, created_at, updated_at,
993              search_text, embedding, embedding_q8, trace_id)
994         SELECT
995             document_id || '-ep0',
996             document_id, cause_ids, effect_type, outcome, confidence,
997             verification_status, experiment_id, created_at, updated_at,
998             search_text, embedding, embedding_q8, trace_id
999         FROM episodes",
1000    )?;
1001
1002    conn.execute_batch("DROP TABLE episodes")?;
1003    conn.execute_batch("ALTER TABLE episodes_new RENAME TO episodes")?;
1004
1005    conn.execute_batch(
1006        "CREATE INDEX idx_episodes_document_id ON episodes(document_id);
1007         CREATE INDEX idx_episodes_effect_type ON episodes(effect_type);
1008         CREATE INDEX idx_episodes_outcome ON episodes(outcome);
1009         CREATE INDEX idx_episodes_experiment_id ON episodes(experiment_id);",
1010    )?;
1011
1012    // Rebuild episodes_rowid_map with episode_id
1013    conn.execute_batch(
1014        "DROP TABLE IF EXISTS episodes_rowid_map;
1015         CREATE TABLE episodes_rowid_map (
1016             rowid       INTEGER PRIMARY KEY AUTOINCREMENT,
1017             episode_id  TEXT NOT NULL UNIQUE,
1018             document_id TEXT
1019         );
1020         INSERT INTO episodes_rowid_map (episode_id, document_id)
1021         SELECT episode_id, document_id FROM episodes;",
1022    )?;
1023
1024    // Rebuild episodes FTS
1025    conn.execute_batch(
1026        "DROP TABLE IF EXISTS episodes_fts;
1027         CREATE VIRTUAL TABLE episodes_fts USING fts5(
1028             content,
1029             content='',
1030             content_rowid='rowid',
1031             tokenize='porter unicode61'
1032         );
1033         INSERT INTO episodes_fts (rowid, content)
1034         SELECT rm.rowid, e.search_text
1035         FROM episodes_rowid_map rm
1036         JOIN episodes e ON e.episode_id = rm.episode_id;",
1037    )?;
1038
1039    // Normalized causal edge table
1040    conn.execute_batch(
1041        "CREATE TABLE IF NOT EXISTS episode_causes (
1042             episode_id    TEXT NOT NULL,
1043             cause_node_id TEXT NOT NULL,
1044             ordinal       INTEGER NOT NULL DEFAULT 0,
1045             PRIMARY KEY (episode_id, cause_node_id)
1046         );
1047         CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
1048    )?;
1049
1050    // Populate edge table from existing JSON cause_ids
1051    conn.execute_batch(
1052        "INSERT OR IGNORE INTO episode_causes (episode_id, cause_node_id, ordinal)
1053         SELECT e.episode_id, je.value, CAST(je.key AS INTEGER)
1054         FROM episodes e, json_each(e.cause_ids) je;",
1055    )?;
1056
1057    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
1058
1059    Ok(())
1060}
1061
1062/// How thorough the integrity check should be.
1063#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1064pub enum VerifyMode {
1065    /// Quick: counts and basic metadata only.
1066    Quick,
1067    /// Full: includes FTS, JSON/enum decoding, blobs, and SQLite integrity_check.
1068    Full,
1069}
1070
1071/// Result of an integrity verification.
1072#[derive(Debug, Clone)]
1073pub struct IntegrityReport {
1074    pub ok: bool,
1075    pub schema_version: u32,
1076    pub fact_count: usize,
1077    pub chunk_count: usize,
1078    pub message_count: usize,
1079    pub facts_missing_embeddings: usize,
1080    pub chunks_missing_embeddings: usize,
1081    pub issues: Vec<String>,
1082}
1083
1084/// Action to take when integrity issues are found.
1085#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086pub enum ReconcileAction {
1087    ReportOnly,
1088    RebuildFts,
1089    ReEmbed,
1090}
1091
1092/// Desired HNSW sidecar mutation queued in SQLite.
1093#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1094pub(crate) enum IndexOpKind {
1095    Upsert,
1096    Delete,
1097}
1098
1099impl IndexOpKind {
1100    pub(crate) fn as_str(self) -> &'static str {
1101        match self {
1102            Self::Upsert => "upsert",
1103            Self::Delete => "delete",
1104        }
1105    }
1106
1107    fn parse(raw: &str, item_key: &str) -> Result<Self, MemoryError> {
1108        match raw {
1109            "upsert" => Ok(Self::Upsert),
1110            "delete" => Ok(Self::Delete),
1111            other => Err(MemoryError::CorruptData {
1112                table: "pending_index_ops",
1113                row_id: item_key.to_string(),
1114                detail: format!("invalid op_kind '{other}'"),
1115            }),
1116        }
1117    }
1118}
1119
1120/// Durable sidecar repair record.
1121#[derive(Debug, Clone)]
1122pub(crate) struct PendingIndexOp {
1123    pub item_key: String,
1124    pub entity_type: String,
1125    pub op_kind: IndexOpKind,
1126    pub attempt_count: u32,
1127    pub last_error: Option<String>,
1128}
1129
1130/// Run a closure inside an unchecked transaction, committing on success.
1131pub fn with_transaction<F, T>(conn: &Connection, f: F) -> Result<T, MemoryError>
1132where
1133    F: FnOnce(&rusqlite::Transaction<'_>) -> Result<T, MemoryError>,
1134{
1135    let tx = conn.unchecked_transaction()?;
1136    let result = f(&tx)?;
1137    tx.commit()?;
1138    Ok(result)
1139}
1140
1141/// Open or create a SQLite database, configure pragmas, and run migrations.
1142pub fn open_database(
1143    path: &Path,
1144    pool: &PoolConfig,
1145    limits: &MemoryLimits,
1146) -> Result<Connection, MemoryError> {
1147    open_database_internal(path, pool, limits.max_db_size_bytes, true)
1148}
1149
1150/// Open a SQLite connection with pragmas applied but without running migrations.
1151#[allow(dead_code)] // public API — used by external consumers, not internally
1152pub fn open_database_connection(
1153    path: &Path,
1154    pool: &PoolConfig,
1155    limits: &MemoryLimits,
1156) -> Result<Connection, MemoryError> {
1157    open_database_internal(path, pool, limits.max_db_size_bytes, false)
1158}
1159
1160pub(crate) fn open_database_internal(
1161    path: &Path,
1162    pool: &PoolConfig,
1163    max_db_size_bytes: u64,
1164    run_schema_migrations: bool,
1165) -> Result<Connection, MemoryError> {
1166    create_parent_dirs(path)?;
1167    let conn = Connection::open(path)?;
1168    configure_connection(&conn, path, pool, max_db_size_bytes, false)?;
1169    if run_schema_migrations {
1170        run_migrations(&conn)?;
1171    }
1172    Ok(conn)
1173}
1174
1175pub(crate) fn open_pool_member_connection(
1176    path: &Path,
1177    pool: &PoolConfig,
1178    limits: &MemoryLimits,
1179    query_only: bool,
1180) -> Result<Connection, MemoryError> {
1181    create_parent_dirs(path)?;
1182    let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE;
1183    let conn = Connection::open_with_flags(path, flags)?;
1184    configure_connection(&conn, path, pool, limits.max_db_size_bytes, query_only)?;
1185    Ok(conn)
1186}
1187
1188fn create_parent_dirs(path: &Path) -> Result<(), MemoryError> {
1189    if let Some(parent) = path.parent() {
1190        if !parent.as_os_str().is_empty() {
1191            std::fs::create_dir_all(parent).map_err(|e| {
1192                MemoryError::StorageError(format!(
1193                    "failed to create database directory {}: {}",
1194                    parent.display(),
1195                    e
1196                ))
1197            })?;
1198        }
1199    }
1200    Ok(())
1201}
1202
1203fn configure_connection(
1204    conn: &Connection,
1205    path: &Path,
1206    pool: &PoolConfig,
1207    max_db_size_bytes: u64,
1208    query_only: bool,
1209) -> Result<(), MemoryError> {
1210    let journal_mode = if pool.enable_wal { "WAL" } else { "DELETE" };
1211    conn.execute_batch(&format!(
1212        "PRAGMA journal_mode = {};
1213         PRAGMA foreign_keys = ON;
1214         PRAGMA busy_timeout = {};
1215         PRAGMA synchronous = NORMAL;
1216         PRAGMA temp_store = MEMORY;
1217         PRAGMA wal_autocheckpoint = {};
1218         PRAGMA cache_size = -25600;
1219         PRAGMA mmap_size = 268435456;",
1220        journal_mode, pool.busy_timeout_ms, pool.wal_autocheckpoint,
1221    ))?;
1222
1223    if query_only {
1224        conn.execute_batch("PRAGMA query_only = ON;")?;
1225    }
1226
1227    let actual_journal_mode: String =
1228        conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
1229    let expected_journal_mode = if pool.enable_wal { "wal" } else { "delete" };
1230    if actual_journal_mode.to_lowercase() != expected_journal_mode {
1231        return Err(MemoryError::StorageError(format!(
1232            "SQLite journal mode mismatch for {}: requested {}, got {}",
1233            path.display(),
1234            expected_journal_mode,
1235            actual_journal_mode
1236        )));
1237    }
1238
1239    if max_db_size_bytes > 0 {
1240        let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
1241        let max_page_count = max_db_size_bytes.div_ceil(page_size);
1242
1243        // SM-AUD-0065: Validate max_page_count before setting pragma
1244        const MAX_SQLITE_PAGE_COUNT: u64 = 1_073_741_823; // SQLite hard limit
1245        const MIN_SQLITE_PAGE_COUNT: u64 = 1;
1246        if !(MIN_SQLITE_PAGE_COUNT..=MAX_SQLITE_PAGE_COUNT).contains(&max_page_count) {
1247            return Err(MemoryError::StorageError(format!(
1248                "Invalid max_page_count {}: must be between {} and {}",
1249                max_page_count, MIN_SQLITE_PAGE_COUNT, MAX_SQLITE_PAGE_COUNT
1250            )));
1251        }
1252
1253        let actual_max_page_count: u64 = conn.query_row(
1254            &format!("PRAGMA max_page_count = {}", max_page_count),
1255            [],
1256            |row| row.get(0),
1257        )?;
1258        let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
1259
1260        if page_count > actual_max_page_count {
1261            return Err(MemoryError::DatabaseSizeLimitExceeded {
1262                current: page_count.saturating_mul(page_size),
1263                limit: max_db_size_bytes,
1264            });
1265        }
1266    }
1267
1268    // SM-AUD-0064: Assert foreign_keys is ON after configuration
1269    let foreign_keys_enabled: bool = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
1270    if !foreign_keys_enabled {
1271        return Err(MemoryError::StorageError(
1272            "PRAGMA foreign_keys failed to enable after configuration".to_string(),
1273        ));
1274    }
1275
1276    Ok(())
1277}
1278
1279/// Run all pending migrations.
1280pub fn run_migrations(conn: &Connection) -> Result<(), MemoryError> {
1281    let user_version: u32 = conn
1282        .query_row("PRAGMA user_version", [], |row| row.get(0))
1283        .map_err(|e| MemoryError::MigrationFailed {
1284            version: 0,
1285            reason: format!("failed to read PRAGMA user_version: {e}"),
1286        })?;
1287
1288    if user_version > MAX_SCHEMA_VERSION {
1289        return Err(MemoryError::SchemaAhead {
1290            found: user_version,
1291            supported: MAX_SCHEMA_VERSION,
1292        });
1293    }
1294
1295    conn.execute_batch(
1296        "CREATE TABLE IF NOT EXISTS _schema_version (
1297            version     INTEGER PRIMARY KEY,
1298            applied_at  TEXT NOT NULL DEFAULT (datetime('now'))
1299        );",
1300    )?;
1301
1302    for &(version, sql) in MIGRATIONS {
1303        let current_version: u32 = conn
1304            .query_row(
1305                "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1306                [],
1307                |row| row.get(0),
1308            )
1309            .unwrap_or(0);
1310
1311        if current_version >= version {
1312            continue;
1313        }
1314
1315        with_transaction(conn, |tx| {
1316            match version {
1317                9 => run_migration_v9(tx).map_err(|e| MemoryError::MigrationFailed {
1318                    version,
1319                    reason: e.to_string(),
1320                })?,
1321                16 => run_migration_v16(tx).map_err(|e| MemoryError::MigrationFailed {
1322                    version,
1323                    reason: e.to_string(),
1324                })?,
1325                17 => run_migration_v17(tx).map_err(|e| MemoryError::MigrationFailed {
1326                    version,
1327                    reason: e.to_string(),
1328                })?,
1329                20 => run_migration_v20(tx).map_err(|e| MemoryError::MigrationFailed {
1330                    version,
1331                    reason: e.to_string(),
1332                })?,
1333                21 => run_migration_v21(tx).map_err(|e| MemoryError::MigrationFailed {
1334                    version,
1335                    reason: e.to_string(),
1336                })?,
1337                26 => run_migration_v26(tx).map_err(|e| MemoryError::MigrationFailed {
1338                    version,
1339                    reason: e.to_string(),
1340                })?,
1341                28 => run_migration_v28(tx).map_err(|e| MemoryError::MigrationFailed {
1342                    version,
1343                    reason: e.to_string(),
1344                })?,
1345                _ => tx
1346                    .execute_batch(sql)
1347                    .map_err(|e| MemoryError::MigrationFailed {
1348                        version,
1349                        reason: e.to_string(),
1350                    })?,
1351            }
1352            tx.execute(
1353                "INSERT INTO _schema_version (version) VALUES (?1)",
1354                params![version],
1355            )
1356            .map_err(|e| MemoryError::MigrationFailed {
1357                version,
1358                reason: e.to_string(),
1359            })?;
1360            Ok(())
1361        })?;
1362
1363        tracing::info!("Applied migration V{}", version);
1364    }
1365
1366    // Keep the authority surface self-healing if a database carries the V29
1367    // marker but one of its idempotent tables was removed by an interrupted
1368    // setup or an older repair tool.
1369    with_transaction(conn, |tx| {
1370        tx.execute_batch(MIGRATION_V29)
1371            .map_err(MemoryError::Database)
1372    })?;
1373    with_transaction(conn, |tx| {
1374        tx.execute_batch(MIGRATION_V30)
1375            .map_err(MemoryError::Database)
1376    })?;
1377    with_transaction(conn, |tx| {
1378        tx.execute_batch(MIGRATION_V31)
1379            .map_err(MemoryError::Database)
1380    })?;
1381
1382    let final_version: u32 = conn
1383        .query_row(
1384            "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1385            [],
1386            |row| row.get(0),
1387        )
1388        .unwrap_or(0);
1389    conn.execute_batch(&format!("PRAGMA user_version = {};", final_version))?;
1390
1391    Ok(())
1392}
1393
1394fn run_migration_v16(conn: &Connection) -> Result<(), rusqlite::Error> {
1395    add_column_if_missing(conn, "projection_import_log", "kernel_payload_json", "TEXT")?;
1396    add_column_if_missing(
1397        conn,
1398        "projection_import_failures",
1399        "kernel_payload_json",
1400        "TEXT",
1401    )?;
1402    Ok(())
1403}
1404
1405fn run_migration_v17(conn: &Connection) -> Result<(), rusqlite::Error> {
1406    add_column_if_missing(conn, "projection_import_log", "episode_bundle_id", "TEXT")?;
1407    add_column_if_missing(conn, "projection_import_log", "episode_bundle_json", "TEXT")?;
1408    add_column_if_missing(
1409        conn,
1410        "projection_import_log",
1411        "execution_context_json",
1412        "TEXT",
1413    )?;
1414    add_column_if_missing(
1415        conn,
1416        "projection_import_failures",
1417        "episode_bundle_id",
1418        "TEXT",
1419    )?;
1420    add_column_if_missing(
1421        conn,
1422        "projection_import_failures",
1423        "episode_bundle_json",
1424        "TEXT",
1425    )?;
1426    add_column_if_missing(
1427        conn,
1428        "projection_import_failures",
1429        "execution_context_json",
1430        "TEXT",
1431    )?;
1432    Ok(())
1433}
1434
1435fn run_migration_v20(conn: &Connection) -> Result<(), rusqlite::Error> {
1436    add_column_if_missing(conn, "derived_vector_artifacts", "encoded_digest", "TEXT")?;
1437    conn.execute(
1438        "UPDATE derived_vector_artifacts
1439         SET encoded_digest = artifact_digest
1440         WHERE encoded_digest IS NULL OR encoded_digest = ''",
1441        [],
1442    )?;
1443    add_column_if_missing(
1444        conn,
1445        "derived_vector_artifacts",
1446        "encoding",
1447        "TEXT NOT NULL DEFAULT 'turbo_code_wire_v1'",
1448    )?;
1449    add_column_if_missing(
1450        conn,
1451        "derived_vector_artifacts",
1452        "dim",
1453        "INTEGER NOT NULL DEFAULT 0",
1454    )?;
1455    add_column_if_missing(
1456        conn,
1457        "derived_vector_artifacts",
1458        "status",
1459        "TEXT NOT NULL DEFAULT 'active'",
1460    )?;
1461    conn.execute_batch(
1462        "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
1463         ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
1464         CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
1465         ON derived_vector_artifacts(source_embedding_digest);",
1466    )?;
1467    Ok(())
1468}
1469
1470fn run_migration_v21(conn: &Connection) -> Result<(), rusqlite::Error> {
1471    conn.execute_batch(MIGRATION_V21)?;
1472    add_column_if_missing(conn, "derived_vector_artifacts", "generation_id", "TEXT")?;
1473    conn.execute_batch(
1474        "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_generation
1475         ON derived_vector_artifacts(generation_id, status);",
1476    )?;
1477    Ok(())
1478}
1479
1480const SEARCH_RECEIPT_SCHEMA_VERSION: &str = "vector_search_receipt_v1";
1481
1482#[derive(Debug, Serialize, Deserialize)]
1483struct StoredVectorSearchReceiptV1 {
1484    #[serde(default = "default_search_receipt_schema_version")]
1485    schema_version: String,
1486    receipt_id: String,
1487    evaluation_time: DateTime<Utc>,
1488    #[serde(default)]
1489    receipt_digest: Option<String>,
1490    #[serde(default)]
1491    trace_id: Option<String>,
1492    #[serde(default)]
1493    attempt_family_id: Option<String>,
1494    #[serde(default)]
1495    attempt_id: Option<String>,
1496    #[serde(default)]
1497    replay_of: Option<String>,
1498    query_embedding_digest: Option<String>,
1499    #[serde(default)]
1500    query_text_digest: Option<String>,
1501    #[serde(default)]
1502    query_input_digest: Option<String>,
1503    #[serde(default)]
1504    filter_digest: Option<String>,
1505    #[serde(default)]
1506    redaction_state: Option<String>,
1507    #[serde(default)]
1508    budget_id: Option<String>,
1509    #[serde(default)]
1510    deadline_at: Option<DateTime<Utc>>,
1511    search_profile: String,
1512    candidate_backend: String,
1513    codec_family: Option<String>,
1514    codec_profile_digest: Option<String>,
1515    #[serde(default)]
1516    artifact_profile_digest: Option<String>,
1517    #[serde(default)]
1518    artifact_count: Option<u64>,
1519    #[serde(default)]
1520    artifact_corruption_count: Option<u64>,
1521    #[serde(default)]
1522    artifact_missing_count: Option<u64>,
1523    #[serde(default)]
1524    vector_artifact_manifest_digest: Option<String>,
1525    #[serde(default)]
1526    artifact_generation_id: Option<String>,
1527    #[serde(default)]
1528    approximate_scanned_count: Option<u64>,
1529    #[serde(default)]
1530    approximate_returned_count: Option<u64>,
1531    #[serde(default)]
1532    raw_rows_loaded_count: Option<u64>,
1533    #[serde(default)]
1534    filter_strategy: Option<String>,
1535    #[serde(default)]
1536    vector_artifact_count: Option<u64>,
1537    #[serde(default)]
1538    vector_artifact_missing_count: Option<u64>,
1539    #[serde(default)]
1540    vector_artifact_stale_count: Option<u64>,
1541    #[serde(default)]
1542    exact_rerank_count: Option<u64>,
1543    #[serde(default)]
1544    approximate_candidate_count: Option<u64>,
1545    #[serde(default)]
1546    fallback_reason: Option<String>,
1547    approximate: bool,
1548    requested_candidates: u64,
1549    returned_candidates: u64,
1550    post_filter_candidates: u64,
1551    #[serde(default)]
1552    sparse_enabled: bool,
1553    #[serde(default)]
1554    sparse_weight: Option<f64>,
1555    #[serde(default)]
1556    sparse_query_nonzero_count: Option<u64>,
1557    #[serde(default)]
1558    sparse_candidate_count: Option<u64>,
1559    #[serde(default)]
1560    sparse_representations: Vec<String>,
1561    #[serde(default)]
1562    sparse_result_ranks: Vec<SparseRankReceiptV1>,
1563    fallback: Option<String>,
1564    exact_rerank: bool,
1565    result_ids: Vec<String>,
1566    degradations: Vec<String>,
1567}
1568
1569fn default_search_receipt_schema_version() -> String {
1570    SEARCH_RECEIPT_SCHEMA_VERSION.to_string()
1571}
1572
1573fn b3_digest(bytes: &[u8]) -> String {
1574    format!("blake3:{}", ContentDigest::compute(bytes).hex())
1575}
1576
1577/// Row from the derived vector artifact store.
1578#[cfg(feature = "turbo-quant-codec")]
1579#[derive(Debug, Clone)]
1580pub(crate) struct DerivedVectorArtifactRow {
1581    pub item_key: String,
1582    pub generation_id: Option<String>,
1583    pub codec_family: String,
1584    pub codec_profile_digest: String,
1585    pub source_embedding_digest: String,
1586    pub encoded_digest: String,
1587    pub encoding: String,
1588    pub dim: usize,
1589    pub status: String,
1590    pub encoded: Vec<u8>,
1591    // Codec governance columns (V23 migration)
1592    pub codec_governance_receipt_id: Option<String>,
1593    pub codec_profile: Option<String>,
1594    pub degradation_budget: Option<f64>,
1595    pub raw_source_artifact_id: Option<String>,
1596}
1597
1598/// Active derived vector artifact generation row.
1599#[cfg(feature = "turbo-quant-codec")]
1600#[derive(Debug, Clone)]
1601#[allow(dead_code)]
1602pub(crate) struct DerivedVectorArtifactGenerationRow {
1603    pub generation_id: String,
1604    pub codec_family: String,
1605    pub codec_profile_digest: String,
1606    pub source_snapshot_digest: String,
1607    pub source_row_count: usize,
1608    pub artifact_count: usize,
1609    pub dim: usize,
1610    pub encoding: String,
1611    pub artifact_manifest_digest: String,
1612    pub status: String,
1613}
1614
1615/// Stable digest for an authoritative raw f32 embedding BLOB.
1616#[cfg(feature = "turbo-quant-codec")]
1617pub(crate) fn source_embedding_digest(
1618    blob: &[u8],
1619    expected_dim: usize,
1620) -> Result<String, MemoryError> {
1621    validate_vector_blob_len(blob, expected_dim)?;
1622    let mut builder = DigestBuilder::new();
1623    builder
1624        .update_str("semantic-memory.source_embedding.v1")
1625        .separator()
1626        .update(&(expected_dim as u64).to_le_bytes())
1627        .separator()
1628        .update(blob);
1629    Ok(format!("blake3:{}", builder.finalize().hex()))
1630}
1631
1632#[cfg(feature = "turbo-quant-codec")]
1633fn source_snapshot_digest(rows: &[DerivedVectorArtifactRow], dim: usize) -> String {
1634    let mut entries = rows
1635        .iter()
1636        .map(|row| (row.item_key.as_str(), row.source_embedding_digest.as_str()))
1637        .collect::<Vec<_>>();
1638    entries.sort_unstable();
1639
1640    let mut builder = DigestBuilder::new();
1641    builder
1642        .update_str("semantic-memory.vector_source_snapshot.v1")
1643        .separator()
1644        .update(&(dim as u64).to_le_bytes())
1645        .separator();
1646    for (item_key, source_embedding_digest) in entries {
1647        builder
1648            .update_str(item_key)
1649            .separator()
1650            .update_str(source_embedding_digest)
1651            .separator();
1652    }
1653    format!("blake3:{}", builder.finalize().hex())
1654}
1655
1656#[cfg(feature = "turbo-quant-codec")]
1657pub(crate) fn current_source_snapshot_digest(
1658    conn: &Connection,
1659    dim: usize,
1660) -> Result<(String, usize), MemoryError> {
1661    let mut stmt = conn.prepare(
1662        "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1663         UNION ALL
1664         SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1665         UNION ALL
1666         SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1667         UNION ALL
1668         SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1669    )?;
1670    let rows = stmt.query_map([], |row| {
1671        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1672    })?;
1673
1674    let mut entries = Vec::new();
1675    for row in rows {
1676        let (item_key, blob) = row?;
1677        entries.push((item_key, source_embedding_digest(&blob, dim)?));
1678    }
1679    entries.sort_unstable();
1680
1681    let mut builder = DigestBuilder::new();
1682    builder
1683        .update_str("semantic-memory.vector_source_snapshot.v1")
1684        .separator()
1685        .update(&(dim as u64).to_le_bytes())
1686        .separator();
1687    for (item_key, source_embedding_digest) in &entries {
1688        builder
1689            .update_str(item_key)
1690            .separator()
1691            .update_str(source_embedding_digest)
1692            .separator();
1693    }
1694    Ok((
1695        format!("blake3:{}", builder.finalize().hex()),
1696        entries.len(),
1697    ))
1698}
1699
1700#[cfg(feature = "turbo-quant-codec")]
1701fn derived_artifact_manifest_digest(rows: &[DerivedVectorArtifactRow]) -> String {
1702    let mut entries = rows
1703        .iter()
1704        .map(|row| {
1705            (
1706                row.item_key.as_str(),
1707                row.source_embedding_digest.as_str(),
1708                row.encoded_digest.as_str(),
1709            )
1710        })
1711        .collect::<Vec<_>>();
1712    entries.sort_unstable();
1713
1714    let mut builder = DigestBuilder::new();
1715    builder
1716        .update_str("semantic-memory.vector_artifact_manifest.v1")
1717        .separator();
1718    for (item_key, source_embedding_digest, encoded_digest) in entries {
1719        builder
1720            .update_str(item_key)
1721            .separator()
1722            .update_str(source_embedding_digest)
1723            .separator()
1724            .update_str(encoded_digest)
1725            .separator();
1726    }
1727    format!("blake3:{}", builder.finalize().hex())
1728}
1729
1730#[cfg(feature = "turbo-quant-codec")]
1731pub(crate) fn upsert_derived_vector_artifact(
1732    conn: &Connection,
1733    row: &DerivedVectorArtifactRow,
1734) -> Result<(), MemoryError> {
1735    conn.execute(
1736        "INSERT OR REPLACE INTO derived_vector_artifacts
1737             (item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1738              encoded_digest, artifact_digest, encoding, dim, encoded, created_at, status,
1739              codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id)
1740         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8, ?9, datetime('now'), ?10, ?11, ?12, ?13, ?14)",
1741        params![
1742            row.item_key,
1743            row.generation_id.as_deref(),
1744            row.codec_family,
1745            row.codec_profile_digest,
1746            row.source_embedding_digest,
1747            row.encoded_digest,
1748            row.encoding,
1749            i64::try_from(row.dim)
1750                .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1751            row.encoded,
1752            row.status,
1753            row.codec_governance_receipt_id.as_deref(),
1754            row.codec_profile.as_deref(),
1755            row.degradation_budget,
1756            row.raw_source_artifact_id.as_deref(),
1757        ],
1758    )?;
1759    Ok(())
1760}
1761
1762#[allow(dead_code)] // public API — used by external consumers, not internally
1763pub fn delete_derived_vector_artifact(
1764    conn: &Connection,
1765    item_key: &str,
1766) -> Result<(), MemoryError> {
1767    conn.execute(
1768        "DELETE FROM derived_vector_artifacts WHERE item_key = ?1",
1769        params![item_key],
1770    )?;
1771    Ok(())
1772}
1773
1774pub fn invalidate_derived_vector_artifact(
1775    conn: &Connection,
1776    item_key: &str,
1777) -> Result<(), MemoryError> {
1778    conn.execute(
1779        "UPDATE derived_vector_artifacts
1780         SET status = 'invalidated'
1781         WHERE item_key = ?1 AND status = 'active'",
1782        params![item_key],
1783    )?;
1784    conn.execute(
1785        "UPDATE derived_vector_artifact_generations
1786         SET status = 'invalidated'
1787         WHERE status = 'active'",
1788        [],
1789    )?;
1790    Ok(())
1791}
1792
1793#[cfg(feature = "turbo-quant-codec")]
1794#[allow(dead_code)]
1795pub(crate) fn load_derived_vector_artifacts_by_profile(
1796    conn: &Connection,
1797    codec_family: &str,
1798    codec_profile_digest: &str,
1799) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1800    let mut stmt = conn.prepare(
1801        "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1802                encoded_digest, encoding, dim, status, encoded,
1803                codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1804         FROM derived_vector_artifacts
1805         WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1806    )?;
1807    let rows = stmt.query_map(params![codec_family, codec_profile_digest], |row| {
1808        let dim_i64: i64 = row.get(7)?;
1809        Ok(DerivedVectorArtifactRow {
1810            item_key: row.get(0)?,
1811            generation_id: row.get(1)?,
1812            codec_family: row.get(2)?,
1813            codec_profile_digest: row.get(3)?,
1814            source_embedding_digest: row.get(4)?,
1815            encoded_digest: row.get(5)?,
1816            encoding: row.get(6)?,
1817            dim: usize::try_from(dim_i64).map_err(|err| {
1818                rusqlite::Error::FromSqlConversionFailure(
1819                    7,
1820                    rusqlite::types::Type::Integer,
1821                    Box::new(err),
1822                )
1823            })?,
1824            status: row.get(8)?,
1825            encoded: row.get(9)?,
1826            codec_governance_receipt_id: row.get(10)?,
1827            codec_profile: row.get(11)?,
1828            degradation_budget: row.get(12)?,
1829            raw_source_artifact_id: row.get(13)?,
1830        })
1831    })?;
1832
1833    let mut artifacts = Vec::new();
1834    for row in rows {
1835        artifacts.push(row?);
1836    }
1837    Ok(artifacts)
1838}
1839
1840#[cfg(feature = "turbo-quant-codec")]
1841pub(crate) fn load_derived_vector_artifacts_by_generation(
1842    conn: &Connection,
1843    generation_id: &str,
1844) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1845    let mut stmt = conn.prepare(
1846        "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1847                encoded_digest, encoding, dim, status, encoded,
1848                codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1849         FROM derived_vector_artifacts
1850         WHERE generation_id = ?1 AND status = 'active'",
1851    )?;
1852    let rows = stmt.query_map(params![generation_id], |row| {
1853        let dim_i64: i64 = row.get(7)?;
1854        Ok(DerivedVectorArtifactRow {
1855            item_key: row.get(0)?,
1856            generation_id: row.get(1)?,
1857            codec_family: row.get(2)?,
1858            codec_profile_digest: row.get(3)?,
1859            source_embedding_digest: row.get(4)?,
1860            encoded_digest: row.get(5)?,
1861            encoding: row.get(6)?,
1862            dim: usize::try_from(dim_i64).map_err(|err| {
1863                rusqlite::Error::FromSqlConversionFailure(
1864                    7,
1865                    rusqlite::types::Type::Integer,
1866                    Box::new(err),
1867                )
1868            })?,
1869            status: row.get(8)?,
1870            encoded: row.get(9)?,
1871            codec_governance_receipt_id: row.get(10)?,
1872            codec_profile: row.get(11)?,
1873            degradation_budget: row.get(12)?,
1874            raw_source_artifact_id: row.get(13)?,
1875        })
1876    })?;
1877
1878    let mut artifacts = Vec::new();
1879    for row in rows {
1880        artifacts.push(row?);
1881    }
1882    Ok(artifacts)
1883}
1884
1885#[cfg(feature = "turbo-quant-codec")]
1886pub(crate) fn current_derived_vector_generation(
1887    conn: &Connection,
1888    codec_family: &str,
1889    codec_profile_digest: &str,
1890) -> Result<Option<DerivedVectorArtifactGenerationRow>, MemoryError> {
1891    conn.query_row(
1892        "SELECT generation_id, codec_family, codec_profile_digest, source_snapshot_digest,
1893                source_row_count, artifact_count, dim, encoding, artifact_manifest_digest, status
1894         FROM derived_vector_artifact_generations
1895         WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'
1896         ORDER BY created_at DESC
1897         LIMIT 1",
1898        params![codec_family, codec_profile_digest],
1899        |row| {
1900            let source_row_count: i64 = row.get(4)?;
1901            let artifact_count: i64 = row.get(5)?;
1902            let dim: i64 = row.get(6)?;
1903            Ok(DerivedVectorArtifactGenerationRow {
1904                generation_id: row.get(0)?,
1905                codec_family: row.get(1)?,
1906                codec_profile_digest: row.get(2)?,
1907                source_snapshot_digest: row.get(3)?,
1908                source_row_count: usize::try_from(source_row_count).map_err(|err| {
1909                    rusqlite::Error::FromSqlConversionFailure(
1910                        4,
1911                        rusqlite::types::Type::Integer,
1912                        Box::new(err),
1913                    )
1914                })?,
1915                artifact_count: usize::try_from(artifact_count).map_err(|err| {
1916                    rusqlite::Error::FromSqlConversionFailure(
1917                        5,
1918                        rusqlite::types::Type::Integer,
1919                        Box::new(err),
1920                    )
1921                })?,
1922                dim: usize::try_from(dim).map_err(|err| {
1923                    rusqlite::Error::FromSqlConversionFailure(
1924                        6,
1925                        rusqlite::types::Type::Integer,
1926                        Box::new(err),
1927                    )
1928                })?,
1929                encoding: row.get(7)?,
1930                artifact_manifest_digest: row.get(8)?,
1931                status: row.get(9)?,
1932            })
1933        },
1934    )
1935    .optional()
1936    .map_err(MemoryError::from)
1937}
1938
1939#[allow(dead_code)] // public API — used by external consumers, not internally
1940pub fn count_derived_vector_artifacts(
1941    conn: &Connection,
1942    codec_family: &str,
1943    codec_profile_digest: &str,
1944) -> Result<usize, MemoryError> {
1945    let count: i64 = conn.query_row(
1946        "SELECT COUNT(*) FROM derived_vector_artifacts
1947         WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1948        params![codec_family, codec_profile_digest],
1949        |row| row.get(0),
1950    )?;
1951    usize::try_from(count)
1952        .map_err(|err| MemoryError::Other(format!("derived artifact count overflow: {err}")))
1953}
1954
1955#[cfg(feature = "turbo-quant-codec")]
1956pub(crate) fn rebuild_turbo_quant_artifacts(
1957    conn: &Connection,
1958    dim: usize,
1959    bits: u8,
1960    projections: usize,
1961    seed: u64,
1962) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1963    use crate::vector_codec::{TurboQuantCodec, VectorCodec};
1964
1965    let started = std::time::Instant::now();
1966    let codec = TurboQuantCodec::new(dim, bits, projections, seed)?;
1967    let codec_profile_digest = codec.profile().digest();
1968    let generation_id = uuid::Uuid::new_v4().to_string();
1969    let mut source_row_count = 0usize;
1970    let mut artifact_count = 0usize;
1971    let mut skipped_row_count = 0usize;
1972    let mut degradations = Vec::new();
1973
1974    let mut stmt = conn.prepare(
1975        "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1976         UNION ALL
1977         SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1978         UNION ALL
1979         SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1980         UNION ALL
1981         SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1982    )?;
1983    let rows = stmt.query_map([], |row| {
1984        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1985    })?;
1986
1987    let mut pending = Vec::new();
1988    for row in rows {
1989        let (item_key, blob) = row?;
1990        source_row_count += 1;
1991        let embedding = match decode_f32_le(&blob, dim) {
1992            Ok(embedding) => embedding,
1993            Err(err) => {
1994                skipped_row_count += 1;
1995                degradations.push(format!(
1996                    "skipped {item_key}: invalid authoritative embedding: {err}"
1997                ));
1998                continue;
1999            }
2000        };
2001        let artifact = match codec.encode(&embedding) {
2002            Ok(artifact) => artifact,
2003            Err(err) => {
2004                skipped_row_count += 1;
2005                degradations.push(format!("skipped {item_key}: encode failed: {err}"));
2006                continue;
2007            }
2008        };
2009        pending.push(DerivedVectorArtifactRow {
2010            item_key,
2011            generation_id: Some(generation_id.clone()),
2012            codec_family: "turbo_quant".to_string(),
2013            codec_profile_digest: codec_profile_digest.clone(),
2014            source_embedding_digest: source_embedding_digest(&blob, dim)?,
2015            encoded_digest: artifact.artifact_digest,
2016            encoding: "turbo_code_wire_v1".to_string(),
2017            dim,
2018            status: "active".to_string(),
2019            encoded: artifact.encoded,
2020            // V23 governance columns — populated by encode_governed path; existing
2021            // turbo-quant build path leaves these as None (nullable).
2022            codec_governance_receipt_id: None,
2023            codec_profile: None,
2024            degradation_budget: None,
2025            raw_source_artifact_id: None,
2026        });
2027    }
2028    drop(stmt);
2029
2030    let build_receipt_id = uuid::Uuid::new_v4().to_string();
2031    let source_snapshot_digest = source_snapshot_digest(&pending, dim);
2032    let artifact_manifest_digest = derived_artifact_manifest_digest(&pending);
2033    let source_tables = vec![
2034        "facts".to_string(),
2035        "chunks".to_string(),
2036        "messages".to_string(),
2037        "episodes".to_string(),
2038    ];
2039    let generation_manifest = DerivedVectorArtifactGenerationV1 {
2040        schema_version: "derived_vector_artifact_generation_v1".to_string(),
2041        generation_id: generation_id.clone(),
2042        codec_family: "turbo_quant".to_string(),
2043        codec_profile_digest: codec_profile_digest.clone(),
2044        source_snapshot_digest: source_snapshot_digest.clone(),
2045        source_row_count,
2046        artifact_count: pending.len(),
2047        source_tables,
2048        dim,
2049        encoding: "turbo_code_wire_v1".to_string(),
2050        created_at: Utc::now(),
2051        build_receipt_id: Some(build_receipt_id.clone()),
2052        artifact_manifest_digest: artifact_manifest_digest.clone(),
2053        status: if skipped_row_count == 0 {
2054            "active".to_string()
2055        } else {
2056            "failed".to_string()
2057        },
2058        degradations: degradations.clone(),
2059    };
2060
2061    with_transaction(conn, |tx| {
2062        tx.execute(
2063            "UPDATE derived_vector_artifact_generations
2064             SET status = 'superseded'
2065             WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
2066            params!["turbo_quant", &codec_profile_digest],
2067        )?;
2068        tx.execute(
2069            "DELETE FROM derived_vector_artifacts
2070             WHERE codec_family = ?1 AND codec_profile_digest = ?2",
2071            params!["turbo_quant", &codec_profile_digest],
2072        )?;
2073        tx.execute(
2074            "INSERT INTO derived_vector_artifact_generations
2075                (generation_id, schema_version, codec_family, codec_profile_digest,
2076                 source_snapshot_digest, source_row_count, artifact_count, source_tables_json,
2077                 dim, encoding, created_at, build_receipt_id, artifact_manifest_digest,
2078                 status, degradations_json)
2079             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
2080            params![
2081                generation_manifest.generation_id,
2082                generation_manifest.schema_version,
2083                generation_manifest.codec_family,
2084                generation_manifest.codec_profile_digest,
2085                generation_manifest.source_snapshot_digest,
2086                i64::try_from(generation_manifest.source_row_count).map_err(|err| {
2087                    MemoryError::Other(format!("source row count overflow: {err}"))
2088                })?,
2089                i64::try_from(generation_manifest.artifact_count).map_err(|err| {
2090                    MemoryError::Other(format!("artifact count overflow: {err}"))
2091                })?,
2092                serde_json::to_string(&generation_manifest.source_tables)
2093                    .map_err(|err| MemoryError::Other(err.to_string()))?,
2094                i64::try_from(generation_manifest.dim)
2095                    .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
2096                generation_manifest.encoding,
2097                generation_manifest.created_at.to_rfc3339(),
2098                generation_manifest.build_receipt_id,
2099                generation_manifest.artifact_manifest_digest,
2100                generation_manifest.status,
2101                serde_json::to_string(&generation_manifest.degradations)
2102                    .map_err(|err| MemoryError::Other(err.to_string()))?,
2103            ],
2104        )?;
2105        for row in &pending {
2106            upsert_derived_vector_artifact(tx, row)?;
2107            artifact_count += 1;
2108        }
2109        Ok(())
2110    })?;
2111
2112    Ok(VectorArtifactBuildReceiptV1 {
2113        schema_version: "vector_artifact_build_receipt_v1".to_string(),
2114        codec_family: "turbo_quant".to_string(),
2115        codec_profile_digest,
2116        source_row_count,
2117        artifact_count,
2118        generation_id: Some(generation_id),
2119        source_snapshot_digest: Some(source_snapshot_digest),
2120        artifact_manifest_digest: Some(artifact_manifest_digest),
2121        build_receipt_id: Some(build_receipt_id),
2122        skipped_row_count,
2123        elapsed_ms: started.elapsed().as_millis(),
2124        created_at: Utc::now(),
2125        degradations,
2126    })
2127}
2128
2129fn receipt_count_to_u64(value: usize, field: &'static str) -> Result<u64, MemoryError> {
2130    u64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2131}
2132
2133fn receipt_count_to_i64(value: u64, field: &'static str) -> Result<i64, MemoryError> {
2134    i64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2135}
2136
2137fn receipt_count_to_usize(
2138    value: u64,
2139    receipt_id: &str,
2140    field: &'static str,
2141) -> Result<usize, MemoryError> {
2142    usize::try_from(value).map_err(|err| MemoryError::CorruptData {
2143        table: "search_receipts",
2144        row_id: receipt_id.to_string(),
2145        detail: format!("{field} does not fit this platform: {err}"),
2146    })
2147}
2148
2149fn stored_search_receipt(
2150    receipt: &VectorSearchReceiptV1,
2151) -> Result<StoredVectorSearchReceiptV1, MemoryError> {
2152    Ok(StoredVectorSearchReceiptV1 {
2153        schema_version: SEARCH_RECEIPT_SCHEMA_VERSION.to_string(),
2154        receipt_id: receipt.receipt_id.clone(),
2155        evaluation_time: receipt.evaluation_time,
2156        receipt_digest: receipt.receipt_digest.clone(),
2157        trace_id: receipt.trace_id.clone(),
2158        attempt_family_id: receipt.attempt_family_id.clone(),
2159        attempt_id: receipt.attempt_id.clone(),
2160        replay_of: receipt.replay_of.clone(),
2161        query_embedding_digest: receipt.query_embedding_digest.clone(),
2162        query_text_digest: receipt.query_text_digest.clone(),
2163        query_input_digest: receipt.query_input_digest.clone(),
2164        filter_digest: receipt.filter_digest.clone(),
2165        redaction_state: receipt.redaction_state.clone(),
2166        budget_id: receipt.budget_id.clone(),
2167        deadline_at: receipt.deadline_at,
2168        search_profile: receipt.search_profile.clone(),
2169        candidate_backend: receipt.candidate_backend.clone(),
2170        codec_family: receipt.codec_family.clone(),
2171        codec_profile_digest: receipt.codec_profile_digest.clone(),
2172        artifact_profile_digest: receipt.artifact_profile_digest.clone(),
2173        artifact_count: receipt
2174            .artifact_count
2175            .map(|value| receipt_count_to_u64(value, "artifact_count"))
2176            .transpose()?,
2177        artifact_corruption_count: receipt
2178            .artifact_corruption_count
2179            .map(|value| receipt_count_to_u64(value, "artifact_corruption_count"))
2180            .transpose()?,
2181        artifact_missing_count: receipt
2182            .artifact_missing_count
2183            .map(|value| receipt_count_to_u64(value, "artifact_missing_count"))
2184            .transpose()?,
2185        vector_artifact_manifest_digest: receipt.vector_artifact_manifest_digest.clone(),
2186        artifact_generation_id: receipt.artifact_generation_id.clone(),
2187        approximate_scanned_count: receipt
2188            .approximate_scanned_count
2189            .map(|value| receipt_count_to_u64(value, "approximate_scanned_count"))
2190            .transpose()?,
2191        approximate_returned_count: receipt
2192            .approximate_returned_count
2193            .map(|value| receipt_count_to_u64(value, "approximate_returned_count"))
2194            .transpose()?,
2195        raw_rows_loaded_count: receipt
2196            .raw_rows_loaded_count
2197            .map(|value| receipt_count_to_u64(value, "raw_rows_loaded_count"))
2198            .transpose()?,
2199        filter_strategy: receipt.filter_strategy.clone(),
2200        vector_artifact_count: receipt
2201            .vector_artifact_count
2202            .map(|value| receipt_count_to_u64(value, "vector_artifact_count"))
2203            .transpose()?,
2204        vector_artifact_missing_count: receipt
2205            .vector_artifact_missing_count
2206            .map(|value| receipt_count_to_u64(value, "vector_artifact_missing_count"))
2207            .transpose()?,
2208        vector_artifact_stale_count: receipt
2209            .vector_artifact_stale_count
2210            .map(|value| receipt_count_to_u64(value, "vector_artifact_stale_count"))
2211            .transpose()?,
2212        exact_rerank_count: receipt
2213            .exact_rerank_count
2214            .map(|value| receipt_count_to_u64(value, "exact_rerank_count"))
2215            .transpose()?,
2216        approximate_candidate_count: receipt
2217            .approximate_candidate_count
2218            .map(|value| receipt_count_to_u64(value, "approximate_candidate_count"))
2219            .transpose()?,
2220        fallback_reason: receipt.fallback_reason.clone(),
2221        approximate: receipt.approximate,
2222        requested_candidates: receipt_count_to_u64(
2223            receipt.requested_candidates,
2224            "requested_candidates",
2225        )?,
2226        returned_candidates: receipt_count_to_u64(
2227            receipt.returned_candidates,
2228            "returned_candidates",
2229        )?,
2230        post_filter_candidates: receipt_count_to_u64(
2231            receipt.post_filter_candidates,
2232            "post_filter_candidates",
2233        )?,
2234        sparse_enabled: receipt.sparse_enabled,
2235        sparse_weight: receipt.sparse_weight,
2236        sparse_query_nonzero_count: receipt
2237            .sparse_query_nonzero_count
2238            .map(|value| receipt_count_to_u64(value, "sparse_query_nonzero_count"))
2239            .transpose()?,
2240        sparse_candidate_count: receipt
2241            .sparse_candidate_count
2242            .map(|value| receipt_count_to_u64(value, "sparse_candidate_count"))
2243            .transpose()?,
2244        sparse_representations: receipt.sparse_representations.clone(),
2245        sparse_result_ranks: receipt.sparse_result_ranks.clone(),
2246        fallback: receipt.fallback.clone(),
2247        exact_rerank: receipt.exact_rerank,
2248        result_ids: receipt.result_ids.clone(),
2249        degradations: receipt.degradations.clone(),
2250    })
2251}
2252
2253fn search_receipt_from_stored(
2254    stored: StoredVectorSearchReceiptV1,
2255) -> Result<VectorSearchReceiptV1, MemoryError> {
2256    if stored.schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2257        return Err(MemoryError::CorruptData {
2258            table: "search_receipts",
2259            row_id: stored.receipt_id,
2260            detail: format!(
2261                "unsupported receipt schema version '{}'",
2262                stored.schema_version
2263            ),
2264        });
2265    }
2266
2267    Ok(VectorSearchReceiptV1 {
2268        schema_version: stored.schema_version.clone(),
2269        receipt_digest: stored.receipt_digest,
2270        receipt_id: stored.receipt_id.clone(),
2271        evaluation_time: stored.evaluation_time,
2272        trace_id: stored.trace_id,
2273        attempt_family_id: stored.attempt_family_id,
2274        attempt_id: stored.attempt_id,
2275        replay_of: stored.replay_of,
2276        query_embedding_digest: stored.query_embedding_digest,
2277        query_text_digest: stored.query_text_digest,
2278        query_input_digest: stored.query_input_digest,
2279        filter_digest: stored.filter_digest,
2280        redaction_state: stored.redaction_state,
2281        budget_id: stored.budget_id,
2282        deadline_at: stored.deadline_at,
2283        search_profile: stored.search_profile,
2284        candidate_backend: stored.candidate_backend,
2285        codec_family: stored.codec_family,
2286        codec_profile_digest: stored.codec_profile_digest,
2287        artifact_profile_digest: stored.artifact_profile_digest,
2288        artifact_count: stored
2289            .artifact_count
2290            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "artifact_count"))
2291            .transpose()?,
2292        artifact_corruption_count: stored
2293            .artifact_corruption_count
2294            .map(|value| {
2295                receipt_count_to_usize(value, &stored.receipt_id, "artifact_corruption_count")
2296            })
2297            .transpose()?,
2298        artifact_missing_count: stored
2299            .artifact_missing_count
2300            .map(|value| {
2301                receipt_count_to_usize(value, &stored.receipt_id, "artifact_missing_count")
2302            })
2303            .transpose()?,
2304        vector_artifact_manifest_digest: stored.vector_artifact_manifest_digest,
2305        artifact_generation_id: stored.artifact_generation_id,
2306        approximate_scanned_count: stored
2307            .approximate_scanned_count
2308            .map(|value| {
2309                receipt_count_to_usize(value, &stored.receipt_id, "approximate_scanned_count")
2310            })
2311            .transpose()?,
2312        approximate_returned_count: stored
2313            .approximate_returned_count
2314            .map(|value| {
2315                receipt_count_to_usize(value, &stored.receipt_id, "approximate_returned_count")
2316            })
2317            .transpose()?,
2318        raw_rows_loaded_count: stored
2319            .raw_rows_loaded_count
2320            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "raw_rows_loaded_count"))
2321            .transpose()?,
2322        filter_strategy: stored.filter_strategy,
2323        vector_artifact_count: stored
2324            .vector_artifact_count
2325            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_count"))
2326            .transpose()?,
2327        vector_artifact_missing_count: stored
2328            .vector_artifact_missing_count
2329            .map(|value| {
2330                receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_missing_count")
2331            })
2332            .transpose()?,
2333        vector_artifact_stale_count: stored
2334            .vector_artifact_stale_count
2335            .map(|value| {
2336                receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_stale_count")
2337            })
2338            .transpose()?,
2339        exact_rerank_count: stored
2340            .exact_rerank_count
2341            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "exact_rerank_count"))
2342            .transpose()?,
2343        approximate_candidate_count: stored
2344            .approximate_candidate_count
2345            .map(|value| {
2346                receipt_count_to_usize(value, &stored.receipt_id, "approximate_candidate_count")
2347            })
2348            .transpose()?,
2349        fallback_reason: stored.fallback_reason,
2350        derived_candidate: None,
2351        approximate: stored.approximate,
2352        requested_candidates: receipt_count_to_usize(
2353            stored.requested_candidates,
2354            &stored.receipt_id,
2355            "requested_candidates",
2356        )?,
2357        returned_candidates: receipt_count_to_usize(
2358            stored.returned_candidates,
2359            &stored.receipt_id,
2360            "returned_candidates",
2361        )?,
2362        post_filter_candidates: receipt_count_to_usize(
2363            stored.post_filter_candidates,
2364            &stored.receipt_id,
2365            "post_filter_candidates",
2366        )?,
2367        sparse_enabled: stored.sparse_enabled,
2368        sparse_weight: stored.sparse_weight,
2369        sparse_query_nonzero_count: stored
2370            .sparse_query_nonzero_count
2371            .map(|value| {
2372                receipt_count_to_usize(value, &stored.receipt_id, "sparse_query_nonzero_count")
2373            })
2374            .transpose()?,
2375        sparse_candidate_count: stored
2376            .sparse_candidate_count
2377            .map(|value| {
2378                receipt_count_to_usize(value, &stored.receipt_id, "sparse_candidate_count")
2379            })
2380            .transpose()?,
2381        sparse_representations: stored.sparse_representations,
2382        sparse_result_ranks: stored.sparse_result_ranks,
2383        fallback: stored.fallback,
2384        exact_rerank: stored.exact_rerank,
2385        result_ids: stored.result_ids,
2386        degradations: stored.degradations,
2387    })
2388}
2389
2390/// Persist a search receipt as replay metadata.
2391///
2392/// SQLite rows remain authoritative for memory. This table stores only the
2393/// execution receipt and digest so the search can be addressed later.
2394pub fn store_search_receipt(
2395    conn: &Connection,
2396    receipt: &VectorSearchReceiptV1,
2397) -> Result<(), MemoryError> {
2398    let stored = stored_search_receipt(receipt)?;
2399    let receipt_json = serde_json::to_string(&stored)
2400        .map_err(|err| MemoryError::Other(format!("failed to serialize search receipt: {err}")))?;
2401    let receipt_digest = b3_digest(receipt_json.as_bytes());
2402
2403    let existing_digest: Option<String> = conn
2404        .query_row(
2405            "SELECT receipt_digest FROM search_receipts WHERE receipt_id = ?1",
2406            params![&stored.receipt_id],
2407            |row| row.get(0),
2408        )
2409        .optional()?;
2410    if let Some(existing_digest) = existing_digest {
2411        if existing_digest == receipt_digest {
2412            return Ok(());
2413        }
2414        return Err(MemoryError::SearchReceiptConflict {
2415            receipt_id: stored.receipt_id,
2416        });
2417    }
2418
2419    let result_ids_json = serde_json::to_string(&stored.result_ids).map_err(|err| {
2420        MemoryError::Other(format!(
2421            "failed to serialize search receipt result IDs: {err}"
2422        ))
2423    })?;
2424    conn.execute(
2425        "INSERT INTO search_receipts (
2426            receipt_id,
2427            schema_version,
2428            evaluation_time,
2429            search_profile,
2430            candidate_backend,
2431            approximate,
2432            exact_rerank,
2433            fallback,
2434            requested_candidates,
2435            returned_candidates,
2436            post_filter_candidates,
2437            result_ids_json,
2438            receipt_json,
2439            receipt_digest
2440        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
2441        params![
2442            &stored.receipt_id,
2443            SEARCH_RECEIPT_SCHEMA_VERSION,
2444            stored.evaluation_time.to_rfc3339(),
2445            &stored.search_profile,
2446            &stored.candidate_backend,
2447            if stored.approximate { 1_i64 } else { 0_i64 },
2448            if stored.exact_rerank { 1_i64 } else { 0_i64 },
2449            &stored.fallback,
2450            receipt_count_to_i64(stored.requested_candidates, "requested_candidates")?,
2451            receipt_count_to_i64(stored.returned_candidates, "returned_candidates")?,
2452            receipt_count_to_i64(stored.post_filter_candidates, "post_filter_candidates")?,
2453            &result_ids_json,
2454            &receipt_json,
2455            &receipt_digest,
2456        ],
2457    )?;
2458    Ok(())
2459}
2460
2461/// Load a durable search receipt by receipt/request ID.
2462pub fn get_search_receipt(
2463    conn: &Connection,
2464    receipt_id: &str,
2465) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
2466    let row: Option<(String, String, String)> = conn
2467        .query_row(
2468            "SELECT schema_version, receipt_json, receipt_digest
2469             FROM search_receipts
2470             WHERE receipt_id = ?1",
2471            params![receipt_id],
2472            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2473        )
2474        .optional()?;
2475
2476    let Some((schema_version, receipt_json, receipt_digest)) = row else {
2477        return Ok(None);
2478    };
2479    if schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2480        return Err(MemoryError::CorruptData {
2481            table: "search_receipts",
2482            row_id: receipt_id.to_string(),
2483            detail: format!("unsupported receipt schema version '{schema_version}'"),
2484        });
2485    }
2486
2487    let stored: StoredVectorSearchReceiptV1 =
2488        serde_json::from_str(&receipt_json).map_err(|err| MemoryError::CorruptData {
2489            table: "search_receipts",
2490            row_id: receipt_id.to_string(),
2491            detail: format!("invalid receipt JSON: {err}"),
2492        })?;
2493    let mut receipt = search_receipt_from_stored(stored)?;
2494    receipt.receipt_digest = Some(receipt_digest);
2495    Ok(Some(receipt))
2496}
2497
2498/// Privacy-sensitive inputs retained by explicit replay opt-in.
2499pub(crate) struct ReplayInputs {
2500    pub query_text: String,
2501    pub namespaces: Option<Vec<String>>,
2502    pub source_types: Option<Vec<SearchSourceType>>,
2503}
2504
2505/// Persist the inputs needed to replay a durable search receipt.
2506pub(crate) fn store_replay_inputs(
2507    conn: &Connection,
2508    receipt_id: &str,
2509    query_text: &str,
2510    namespaces: Option<&[&str]>,
2511    source_types: Option<&[SearchSourceType]>,
2512) -> Result<(), MemoryError> {
2513    let namespaces_json = namespaces
2514        .map(serde_json::to_string)
2515        .transpose()
2516        .map_err(|error| {
2517            MemoryError::Other(format!("failed to serialize replay namespaces: {error}"))
2518        })?;
2519    let source_types_json = source_types
2520        .map(serde_json::to_string)
2521        .transpose()
2522        .map_err(|error| {
2523            MemoryError::Other(format!("failed to serialize replay source types: {error}"))
2524        })?;
2525
2526    let existing: Option<(String, Option<String>, Option<String>)> = conn
2527        .query_row(
2528            "SELECT query_text, namespaces_json, source_types_json
2529             FROM replay_inputs WHERE receipt_id = ?1",
2530            params![receipt_id],
2531            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2532        )
2533        .optional()?;
2534    if let Some(existing) = existing {
2535        if existing.0 == query_text
2536            && existing.1.as_ref() == namespaces_json.as_ref()
2537            && existing.2.as_ref() == source_types_json.as_ref()
2538        {
2539            return Ok(());
2540        }
2541        return Err(MemoryError::SearchReceiptConflict {
2542            receipt_id: receipt_id.to_string(),
2543        });
2544    }
2545
2546    conn.execute(
2547        "INSERT INTO replay_inputs
2548            (receipt_id, query_text, namespaces_json, source_types_json)
2549         VALUES (?1, ?2, ?3, ?4)",
2550        params![receipt_id, query_text, namespaces_json, source_types_json],
2551    )?;
2552    Ok(())
2553}
2554
2555/// Load opt-in inputs for complete replay, if present.
2556pub(crate) fn get_replay_inputs(
2557    conn: &Connection,
2558    receipt_id: &str,
2559) -> Result<Option<ReplayInputs>, MemoryError> {
2560    let row: Option<(String, Option<String>, Option<String>)> = conn
2561        .query_row(
2562            "SELECT query_text, namespaces_json, source_types_json
2563             FROM replay_inputs WHERE receipt_id = ?1",
2564            params![receipt_id],
2565            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2566        )
2567        .optional()?;
2568    let Some((query_text, namespaces_json, source_types_json)) = row else {
2569        return Ok(None);
2570    };
2571    let namespaces = namespaces_json
2572        .map(|json| serde_json::from_str(&json))
2573        .transpose()
2574        .map_err(|error| MemoryError::CorruptData {
2575            table: "replay_inputs",
2576            row_id: receipt_id.to_string(),
2577            detail: format!("invalid namespaces JSON: {error}"),
2578        })?;
2579    let source_types = source_types_json
2580        .map(|json| serde_json::from_str(&json))
2581        .transpose()
2582        .map_err(|error| MemoryError::CorruptData {
2583            table: "replay_inputs",
2584            row_id: receipt_id.to_string(),
2585            detail: format!("invalid source types JSON: {error}"),
2586        })?;
2587    Ok(Some(ReplayInputs {
2588        query_text,
2589        namespaces,
2590        source_types,
2591    }))
2592}
2593
2594fn add_column_if_missing(
2595    conn: &Connection,
2596    table: &str,
2597    column: &str,
2598    column_sql: &str,
2599) -> Result<(), rusqlite::Error> {
2600    let pragma = format!("PRAGMA table_info({table})");
2601    let mut stmt = conn.prepare(&pragma)?;
2602    let exists = stmt
2603        .query_map([], |row| row.get::<_, String>(1))?
2604        .collect::<Result<Vec<_>, _>>()?
2605        .into_iter()
2606        .any(|name| name == column);
2607
2608    if !exists {
2609        conn.execute(
2610            &format!("ALTER TABLE {table} ADD COLUMN {column} {column_sql}"),
2611            [],
2612        )?;
2613    }
2614
2615    Ok(())
2616}
2617
2618/// Check and update the embedding metadata singleton row.
2619pub fn check_embedding_metadata(
2620    conn: &Connection,
2621    config: &EmbeddingConfig,
2622) -> Result<(), MemoryError> {
2623    // INTENTIONAL: row absent on first run before metadata is inserted
2624    let existing: Option<(String, usize)> = conn
2625        .query_row(
2626            "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2627            [],
2628            |row| Ok((row.get(0)?, row.get(1)?)),
2629        )
2630        .ok();
2631
2632    match existing {
2633        Some((model, dims)) => {
2634            if model != config.model || dims != config.dimensions {
2635                tracing::warn!(
2636                    stored_model = %model,
2637                    stored_dims = dims,
2638                    configured_model = %config.model,
2639                    configured_dims = config.dimensions,
2640                    "Embedding model changed. Existing embeddings are stale."
2641                );
2642                conn.execute(
2643                    "UPDATE embedding_metadata
2644                     SET model_name = ?1,
2645                         dimensions = ?2,
2646                         embeddings_dirty = 1,
2647                         updated_at = datetime('now')
2648                     WHERE id = 1",
2649                    params![config.model, config.dimensions],
2650                )?;
2651            }
2652        }
2653        None => {
2654            conn.execute(
2655                "INSERT INTO embedding_metadata (id, model_name, dimensions) VALUES (1, ?1, ?2)",
2656                params![config.model, config.dimensions],
2657            )?;
2658        }
2659    }
2660
2661    Ok(())
2662}
2663
2664/// Encode an f32 slice as bytes for SQLite BLOB storage.
2665pub fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
2666    encode_f32_le(embedding)
2667}
2668
2669/// One durable sparse-vector row used by the sparse retrieval lane.
2670#[derive(Debug, Clone)]
2671pub(crate) struct SparseVectorRow {
2672    pub item_key: String,
2673    pub weights: SparseWeights,
2674    pub representation: String,
2675}
2676
2677/// Insert or replace the sparse representation for a canonical search item.
2678pub(crate) fn store_sparse_vector(
2679    conn: &Connection,
2680    item_key: &str,
2681    weights: &SparseWeights,
2682    representation: &str,
2683) -> Result<(), MemoryError> {
2684    if item_key.split_once(':').is_none() || representation.trim().is_empty() {
2685        return Err(MemoryError::InvalidConfig {
2686            field: "sparse_vector",
2687            reason: "item_key must be canonical and representation must not be empty".to_string(),
2688        });
2689    }
2690    if weights
2691        .entries
2692        .iter()
2693        .any(|(_, weight)| !weight.is_finite())
2694    {
2695        return Err(MemoryError::InvalidConfig {
2696            field: "sparse_vector.entries",
2697            reason: "sparse weights must be finite".to_string(),
2698        });
2699    }
2700    let entries_json = serde_json::to_string(&weights.entries)
2701        .map_err(|error| MemoryError::Other(format!("serialize sparse weights: {error}")))?;
2702    conn.execute(
2703        "INSERT INTO sparse_vectors (item_key, entries_json, representation, updated_at)
2704         VALUES (?1, ?2, ?3, datetime('now'))
2705         ON CONFLICT(item_key) DO UPDATE SET
2706             entries_json = excluded.entries_json,
2707             representation = excluded.representation,
2708             updated_at = excluded.updated_at",
2709        params![item_key, entries_json, representation],
2710    )?;
2711    Ok(())
2712}
2713
2714/// Load a bounded set of durable sparse rows selected by sparse dot product.
2715pub(crate) fn search_sparse_vectors(
2716    conn: &Connection,
2717    query: &SparseWeights,
2718    candidate_limit: usize,
2719    min_score: f64,
2720) -> Result<Vec<(SparseVectorRow, f64)>, MemoryError> {
2721    if query.is_empty() || candidate_limit == 0 {
2722        return Ok(Vec::new());
2723    }
2724
2725    let mut sql = String::from("WITH query_sparse(dimension, weight) AS (VALUES ");
2726    for index in 0..query.entries.len() {
2727        if index > 0 {
2728            sql.push(',');
2729        }
2730        let dimension_param = index * 2 + 1;
2731        let weight_param = dimension_param + 1;
2732        sql.push_str(&format!("(?{dimension_param}, ?{weight_param})"));
2733    }
2734    let min_score_param = query.entries.len() * 2 + 1;
2735    let limit_param = min_score_param + 1;
2736    sql.push_str(&format!(
2737        ") SELECT sv.item_key, sv.entries_json, sv.representation,
2738                  SUM(CAST(json_extract(entry.value, '$[1]') AS REAL) * query_sparse.weight) AS sparse_score
2739           FROM sparse_vectors sv
2740           JOIN json_each(sv.entries_json) AS entry
2741           JOIN query_sparse
2742             ON CAST(json_extract(entry.value, '$[0]') AS INTEGER) = query_sparse.dimension
2743           GROUP BY sv.item_key
2744           HAVING sparse_score >= ?{min_score_param}
2745           ORDER BY sparse_score DESC, sv.item_key ASC
2746           LIMIT ?{limit_param}"
2747    ));
2748
2749    let mut values: Vec<rusqlite::types::Value> = Vec::with_capacity(query.entries.len() * 2 + 2);
2750    for (dimension, weight) in &query.entries {
2751        values.push((*dimension as i64).into());
2752        values.push(f64::from(*weight).into());
2753    }
2754    values.push(min_score.into());
2755    values.push((candidate_limit as i64).into());
2756
2757    let mut statement = conn.prepare(&sql)?;
2758    let rows = statement.query_map(rusqlite::params_from_iter(values), |row| {
2759        Ok((
2760            row.get::<_, String>(0)?,
2761            row.get::<_, String>(1)?,
2762            row.get::<_, String>(2)?,
2763            row.get::<_, f64>(3)?,
2764        ))
2765    })?;
2766    let mut results = Vec::new();
2767    for row in rows {
2768        let (item_key, entries_json, representation, score) = row?;
2769        let entries: Vec<(usize, f32)> =
2770            serde_json::from_str(&entries_json).map_err(|error| MemoryError::CorruptData {
2771                table: "sparse_vectors",
2772                row_id: item_key.clone(),
2773                detail: format!("invalid sparse entries JSON: {error}"),
2774            })?;
2775        results.push((
2776            SparseVectorRow {
2777                item_key,
2778                weights: SparseWeights::from_entries(entries),
2779                representation,
2780            },
2781            score,
2782        ));
2783    }
2784    Ok(results)
2785}
2786
2787/// Remove a sparse vector explicitly (used for supersession cleanup).
2788pub(crate) fn delete_sparse_vector(conn: &Connection, item_key: &str) -> Result<(), MemoryError> {
2789    conn.execute(
2790        "DELETE FROM sparse_vectors WHERE item_key = ?1",
2791        params![item_key],
2792    )?;
2793    Ok(())
2794}
2795
2796/// Encode f32 values as a stable little-endian persisted representation.
2797pub fn encode_f32_le(values: &[f32]) -> Vec<u8> {
2798    let mut bytes = Vec::with_capacity(values.len() * 4);
2799    for value in values {
2800        bytes.extend_from_slice(&value.to_le_bytes());
2801    }
2802    bytes
2803}
2804
2805/// Validate an embedding vector before it is stored or indexed.
2806pub(crate) fn validate_embedding(values: &[f32], expected_dim: usize) -> Result<(), MemoryError> {
2807    if values.len() != expected_dim {
2808        return Err(MemoryError::EmbeddingDimensionMismatch {
2809            expected: expected_dim,
2810            actual: values.len(),
2811        });
2812    }
2813    if let Some((index, _)) = values
2814        .iter()
2815        .enumerate()
2816        .find(|(_, value)| !value.is_finite())
2817    {
2818        return Err(MemoryError::NonFiniteEmbeddingValue { index });
2819    }
2820    Ok(())
2821}
2822
2823/// Validate a returned embedding batch against the requested input count.
2824pub(crate) fn validate_embedding_batch(
2825    values: &[Vec<f32>],
2826    requested: usize,
2827    expected_dim: usize,
2828) -> Result<(), MemoryError> {
2829    if values.len() != requested {
2830        return Err(MemoryError::EmbeddingBatchCountMismatch {
2831            requested,
2832            returned: values.len(),
2833        });
2834    }
2835    for embedding in values {
2836        validate_embedding(embedding, expected_dim)?;
2837    }
2838    Ok(())
2839}
2840
2841/// Validate the exact byte length of a persisted f32 vector blob.
2842pub(crate) fn validate_vector_blob_len(
2843    bytes: &[u8],
2844    expected_dim: usize,
2845) -> Result<(), MemoryError> {
2846    let expected_bytes = expected_dim
2847        .checked_mul(4)
2848        .ok_or_else(|| MemoryError::InvalidConfig {
2849            field: "embedding.dimensions",
2850            reason: "dimension byte length overflow".to_string(),
2851        })?;
2852    if bytes.len() != expected_bytes {
2853        return Err(MemoryError::VectorBlobLengthMismatch {
2854            expected_bytes,
2855            actual_bytes: bytes.len(),
2856        });
2857    }
2858    Ok(())
2859}
2860
2861/// Decode a stable little-endian f32 persisted representation.
2862#[allow(clippy::manual_is_multiple_of)]
2863pub fn decode_f32_le(bytes: &[u8], expected_dim: usize) -> Result<Vec<f32>, MemoryError> {
2864    validate_vector_blob_len(bytes, expected_dim)?;
2865    decode_f32_le_unchecked_dim(bytes)
2866}
2867
2868/// Decode a SQLite embedding BLOB back to f32 values.
2869#[allow(clippy::manual_is_multiple_of)]
2870pub fn bytes_to_embedding(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2871    if bytes.len() % 4 != 0 {
2872        return Err(MemoryError::InvalidEmbedding {
2873            expected_bytes: bytes.len() - (bytes.len() % 4),
2874            actual_bytes: bytes.len(),
2875        });
2876    }
2877
2878    decode_f32_le_unchecked_dim(bytes)
2879}
2880
2881fn decode_f32_le_unchecked_dim(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2882    let mut embedding = Vec::with_capacity(bytes.len() / 4);
2883    for (index, chunk) in bytes.chunks_exact(4).enumerate() {
2884        let value = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
2885        if !value.is_finite() {
2886            return Err(MemoryError::NonFiniteEmbeddingValue { index });
2887        }
2888        embedding.push(value);
2889    }
2890    Ok(embedding)
2891}
2892
2893pub fn is_embeddings_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2894    let dirty: i32 = conn
2895        .query_row(
2896            "SELECT COALESCE(embeddings_dirty, 0) FROM embedding_metadata WHERE id = 1",
2897            [],
2898            |row| row.get(0),
2899        )
2900        .unwrap_or(0);
2901    Ok(dirty != 0)
2902}
2903
2904pub fn clear_embeddings_dirty(conn: &Connection) -> Result<(), MemoryError> {
2905    conn.execute(
2906        "UPDATE embedding_metadata SET embeddings_dirty = 0 WHERE id = 1",
2907        [],
2908    )?;
2909    Ok(())
2910}
2911
2912#[cfg(feature = "hnsw")]
2913pub(crate) fn queue_pending_index_op(
2914    tx: &rusqlite::Transaction<'_>,
2915    item_key: &str,
2916    entity_type: &str,
2917    op_kind: IndexOpKind,
2918) -> Result<(), MemoryError> {
2919    tx.execute(
2920        "INSERT INTO pending_index_ops (item_key, entity_type, op_kind, attempt_count, last_error, updated_at)
2921         VALUES (?1, ?2, ?3, 0, NULL, datetime('now'))
2922         ON CONFLICT(item_key) DO UPDATE SET
2923             entity_type = excluded.entity_type,
2924             op_kind = excluded.op_kind,
2925             attempt_count = 0,
2926             last_error = NULL,
2927             updated_at = datetime('now')",
2928        params![item_key, entity_type, op_kind.as_str()],
2929    )?;
2930    mark_sidecar_dirty(tx)?;
2931    Ok(())
2932}
2933
2934#[cfg(feature = "hnsw")]
2935pub(crate) use IndexOpKind as PendingIndexOpKind;
2936
2937#[cfg(feature = "hnsw")]
2938pub(crate) fn enqueue_pending_index_op(
2939    tx: &rusqlite::Transaction<'_>,
2940    item_key: &str,
2941    entity_type: &str,
2942    op_kind: PendingIndexOpKind,
2943) -> Result<(), MemoryError> {
2944    queue_pending_index_op(tx, item_key, entity_type, op_kind)
2945}
2946
2947pub(crate) fn list_pending_index_ops(
2948    conn: &Connection,
2949) -> Result<Vec<PendingIndexOp>, MemoryError> {
2950    let table_exists: bool = conn
2951        .query_row(
2952            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2953            [],
2954            |row| row.get(0),
2955        )
2956        .unwrap_or(false);
2957    if !table_exists {
2958        return Ok(Vec::new());
2959    }
2960
2961    let mut stmt = conn.prepare(
2962        "SELECT item_key, entity_type, op_kind, attempt_count, last_error
2963         FROM pending_index_ops
2964         ORDER BY updated_at ASC, item_key ASC",
2965    )?;
2966    let rows = stmt
2967        .query_map([], |row| {
2968            let item_key: String = row.get(0)?;
2969            let op_kind: String = row.get(2)?;
2970            Ok(PendingIndexOp {
2971                item_key: item_key.clone(),
2972                entity_type: row.get(1)?,
2973                op_kind: IndexOpKind::parse(&op_kind, &item_key).map_err(|e| {
2974                    rusqlite::Error::FromSqlConversionFailure(
2975                        2,
2976                        rusqlite::types::Type::Text,
2977                        Box::new(e),
2978                    )
2979                })?,
2980                attempt_count: row.get::<_, i64>(3)? as u32,
2981                last_error: row.get(4)?,
2982            })
2983        })?
2984        .collect::<Result<Vec<_>, _>>()?;
2985    Ok(rows)
2986}
2987
2988#[cfg(feature = "hnsw")]
2989pub(crate) fn pending_index_op_count(conn: &Connection) -> Result<usize, MemoryError> {
2990    let table_exists: bool = conn
2991        .query_row(
2992            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2993            [],
2994            |row| row.get(0),
2995        )
2996        .unwrap_or(false);
2997    if !table_exists {
2998        return Ok(0);
2999    }
3000
3001    let count: i64 = conn.query_row("SELECT COUNT(*) FROM pending_index_ops", [], |row| {
3002        row.get(0)
3003    })?;
3004    Ok(count as usize)
3005}
3006
3007#[cfg(feature = "hnsw")]
3008pub(crate) fn mark_pending_index_ops_failed(
3009    conn: &Connection,
3010    item_keys: &[String],
3011    error: &str,
3012) -> Result<(), MemoryError> {
3013    with_transaction(conn, |tx| {
3014        for item_key in item_keys {
3015            tx.execute(
3016                "UPDATE pending_index_ops
3017                 SET attempt_count = attempt_count + 1,
3018                     last_error = ?1,
3019                     updated_at = datetime('now')
3020                 WHERE item_key = ?2",
3021                params![error, item_key],
3022            )?;
3023        }
3024        Ok(())
3025    })
3026}
3027
3028#[cfg(feature = "hnsw")]
3029pub(crate) fn clear_pending_index_ops(
3030    conn: &Connection,
3031    item_keys: &[String],
3032) -> Result<(), MemoryError> {
3033    with_transaction(conn, |tx| {
3034        for item_key in item_keys {
3035            tx.execute(
3036                "DELETE FROM pending_index_ops WHERE item_key = ?1",
3037                params![item_key],
3038            )?;
3039        }
3040        Ok(())
3041    })
3042}
3043
3044#[cfg(feature = "hnsw")]
3045pub(crate) fn clear_all_pending_index_ops(conn: &Connection) -> Result<(), MemoryError> {
3046    conn.execute("DELETE FROM pending_index_ops", [])?;
3047    Ok(())
3048}
3049
3050#[cfg(feature = "hnsw")]
3051pub(crate) fn load_embedding_for_index_key(
3052    conn: &Connection,
3053    item_key: &str,
3054) -> Result<Option<Vec<f32>>, MemoryError> {
3055    let Some((domain, raw_id)) = item_key.split_once(':') else {
3056        return Err(MemoryError::InvalidKey(item_key.to_string()));
3057    };
3058
3059    let blob_result: Result<Option<Vec<u8>>, rusqlite::Error> = match domain {
3060        "fact" => conn.query_row(
3061            "SELECT embedding FROM facts WHERE id = ?1",
3062            params![raw_id],
3063            |row| row.get(0),
3064        ),
3065        "chunk" => conn.query_row(
3066            "SELECT embedding FROM chunks WHERE id = ?1",
3067            params![raw_id],
3068            |row| row.get(0),
3069        ),
3070        "msg" => {
3071            let message_id = raw_id
3072                .parse::<i64>()
3073                .map_err(|e| MemoryError::InvalidKey(format!("{}: {e}", item_key)))?;
3074            conn.query_row(
3075                "SELECT embedding FROM messages WHERE id = ?1",
3076                params![message_id],
3077                |row| row.get(0),
3078            )
3079        }
3080        "episode" => conn.query_row(
3081            "SELECT embedding FROM episodes WHERE episode_id = ?1",
3082            params![raw_id],
3083            |row| row.get(0),
3084        ),
3085        _ => return Err(MemoryError::InvalidKey(item_key.to_string())),
3086    };
3087
3088    let blob = match blob_result {
3089        Ok(blob) => blob,
3090        Err(rusqlite::Error::QueryReturnedNoRows) => None,
3091        Err(err) => return Err(err.into()),
3092    };
3093
3094    blob.map(|bytes| bytes_to_embedding(&bytes)).transpose()
3095}
3096
3097#[cfg(feature = "hnsw")]
3098fn mark_sidecar_dirty(tx: &rusqlite::Transaction<'_>) -> Result<(), MemoryError> {
3099    tx.execute(
3100        "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '1')
3101         ON CONFLICT(key) DO UPDATE SET value = '1'",
3102        [],
3103    )?;
3104    Ok(())
3105}
3106
3107#[cfg(feature = "hnsw")]
3108pub(crate) fn is_sidecar_dirty(conn: &Connection) -> Result<bool, MemoryError> {
3109    // INTENTIONAL: row absent when HNSW metadata has not been written yet
3110    let dirty: Option<String> = conn
3111        .query_row(
3112            "SELECT value FROM hnsw_metadata WHERE key = 'sidecar_dirty'",
3113            [],
3114            |row| row.get(0),
3115        )
3116        .ok();
3117    Ok(matches!(dirty.as_deref(), Some("1")))
3118}
3119
3120#[cfg(feature = "hnsw")]
3121pub(crate) fn set_sidecar_dirty(conn: &Connection, dirty: bool) -> Result<(), MemoryError> {
3122    conn.execute(
3123        "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', ?1)
3124         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3125        params![if dirty { "1" } else { "0" }],
3126    )?;
3127    Ok(())
3128}
3129
3130pub(crate) fn parse_optional_json(
3131    table: &'static str,
3132    row_id: &str,
3133    field: &'static str,
3134    raw: Option<&str>,
3135) -> Result<Option<serde_json::Value>, MemoryError> {
3136    match raw {
3137        Some(raw) => serde_json::from_str(raw)
3138            .map(Some)
3139            .map_err(|e| MemoryError::CorruptData {
3140                table,
3141                row_id: row_id.to_string(),
3142                detail: format!("invalid {field}: {e}"),
3143            }),
3144        None => Ok(None),
3145    }
3146}
3147
3148pub(crate) fn parse_string_list_json(
3149    table: &'static str,
3150    row_id: &str,
3151    field: &'static str,
3152    raw: &str,
3153) -> Result<Vec<String>, MemoryError> {
3154    serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3155        table,
3156        row_id: row_id.to_string(),
3157        detail: format!("invalid {field}: {e}"),
3158    })
3159}
3160
3161pub(crate) fn parse_role(
3162    table: &'static str,
3163    row_id: &str,
3164    raw: &str,
3165) -> Result<Role, MemoryError> {
3166    Role::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3167        table,
3168        row_id: row_id.to_string(),
3169        detail: format!("invalid role '{raw}'"),
3170    })
3171}
3172
3173pub(crate) fn parse_episode_outcome(
3174    row_id: &str,
3175    raw: &str,
3176) -> Result<EpisodeOutcome, MemoryError> {
3177    EpisodeOutcome::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3178        table: "episodes",
3179        row_id: row_id.to_string(),
3180        detail: format!("invalid outcome '{raw}'"),
3181    })
3182}
3183
3184pub(crate) fn parse_verification_status(
3185    row_id: &str,
3186    raw: &str,
3187) -> Result<VerificationStatus, MemoryError> {
3188    serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3189        table: "episodes",
3190        row_id: row_id.to_string(),
3191        detail: format!("invalid verification_status: {e}"),
3192    })
3193}
3194
3195/// Run integrity verification on the database.
3196pub fn verify_integrity_sync(
3197    conn: &Connection,
3198    mode: VerifyMode,
3199) -> Result<IntegrityReport, MemoryError> {
3200    let mut issues = Vec::new();
3201
3202    let schema_version: u32 = conn
3203        .query_row("PRAGMA user_version", [], |row| row.get(0))
3204        .unwrap_or_else(|e| {
3205            issues.push(format!("failed to read schema version: {e}"));
3206            0
3207        });
3208    if schema_version > MAX_SCHEMA_VERSION {
3209        issues.push(format!(
3210            "schema version {} is ahead of supported {}",
3211            schema_version, MAX_SCHEMA_VERSION
3212        ));
3213    }
3214
3215    let fact_count: usize = conn
3216        .query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
3217        .unwrap_or_else(|e| {
3218            issues.push(format!("failed to count facts: {e}"));
3219            0
3220        });
3221    let chunk_count: usize = conn
3222        .query_row("SELECT COUNT(*) FROM chunks", [], |row| row.get(0))
3223        .unwrap_or_else(|e| {
3224            issues.push(format!("failed to count chunks: {e}"));
3225            0
3226        });
3227    let message_count: usize = conn
3228        .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
3229        .unwrap_or_else(|e| {
3230            issues.push(format!("failed to count messages: {e}"));
3231            0
3232        });
3233    let episode_count: usize = conn
3234        .query_row("SELECT COUNT(*) FROM episodes", [], |row| row.get(0))
3235        .unwrap_or_else(|e| {
3236            issues.push(format!("failed to count episodes: {e}"));
3237            0
3238        });
3239
3240    let facts_missing_embeddings: usize = conn
3241        .query_row(
3242            "SELECT COUNT(*) FROM facts WHERE embedding IS NULL",
3243            [],
3244            |row| row.get(0),
3245        )
3246        .unwrap_or_else(|e| {
3247            issues.push(format!("failed to count facts missing embeddings: {e}"));
3248            0
3249        });
3250    let chunks_missing_embeddings: usize = conn
3251        .query_row(
3252            "SELECT COUNT(*) FROM chunks WHERE embedding IS NULL",
3253            [],
3254            |row| row.get(0),
3255        )
3256        .unwrap_or_else(|e| {
3257            issues.push(format!("failed to count chunks missing embeddings: {e}"));
3258            0
3259        });
3260    let episodes_missing_embeddings: usize = conn
3261        .query_row(
3262            "SELECT COUNT(*) FROM episodes WHERE embedding IS NULL",
3263            [],
3264            |row| row.get(0),
3265        )
3266        .unwrap_or_else(|e| {
3267            issues.push(format!("failed to count episodes missing embeddings: {e}"));
3268            0
3269        });
3270
3271    if facts_missing_embeddings > 0 {
3272        issues.push(format!(
3273            "{} facts missing embeddings",
3274            facts_missing_embeddings
3275        ));
3276    }
3277    if chunks_missing_embeddings > 0 {
3278        issues.push(format!(
3279            "{} chunks missing embeddings",
3280            chunks_missing_embeddings
3281        ));
3282    }
3283    if episodes_missing_embeddings > 0 {
3284        issues.push(format!(
3285            "{} episodes missing embeddings",
3286            episodes_missing_embeddings
3287        ));
3288    }
3289
3290    let pending_ops = list_pending_index_ops(conn).unwrap_or_default();
3291    if !pending_ops.is_empty() {
3292        issues.push(format!(
3293            "{} pending HNSW sidecar ops queued in SQLite",
3294            pending_ops.len()
3295        ));
3296        for op in pending_ops.iter().take(5) {
3297            let op_kind = op.op_kind.as_str();
3298            let detail = match &op.last_error {
3299                Some(last_error) => format!(
3300                    "{} {} {} (attempts: {}, last_error: {})",
3301                    op.entity_type,
3302                    op.op_kind.as_str(),
3303                    op.item_key,
3304                    op.attempt_count,
3305                    last_error
3306                ),
3307                None => format!(
3308                    "{} {} {} (attempts: {})",
3309                    op.entity_type, op_kind, op.item_key, op.attempt_count
3310                ),
3311            };
3312            issues.push(format!("pending sidecar op: {detail}"));
3313        }
3314    }
3315
3316    if mode == VerifyMode::Full {
3317        let dims: usize = conn
3318            .query_row(
3319                "SELECT dimensions FROM embedding_metadata WHERE id = 1",
3320                [],
3321                |row| row.get(0),
3322            )
3323            .unwrap_or_else(|e| {
3324                issues.push(format!("failed to read embedding dimensions: {e}"));
3325                0
3326            });
3327
3328        verify_fts_drift(conn, "facts", "facts_rowid_map", fact_count, &mut issues);
3329        verify_fts_drift(conn, "chunks", "chunks_rowid_map", chunk_count, &mut issues);
3330        verify_fts_drift(
3331            conn,
3332            "messages",
3333            "messages_rowid_map",
3334            message_count,
3335            &mut issues,
3336        );
3337        verify_fts_drift(
3338            conn,
3339            "episodes",
3340            "episodes_rowid_map",
3341            episode_count,
3342            &mut issues,
3343        );
3344
3345        verify_blob_table(conn, "facts", "id", "embedding", dims, &mut issues)?;
3346        verify_blob_table(conn, "chunks", "id", "embedding", dims, &mut issues)?;
3347        verify_blob_table(conn, "messages", "id", "embedding", dims, &mut issues)?;
3348        verify_blob_table(
3349            conn,
3350            "episodes",
3351            "episode_id",
3352            "embedding",
3353            dims,
3354            &mut issues,
3355        )?;
3356
3357        verify_quantized_table(conn, "facts", "id", dims, &mut issues)?;
3358        verify_quantized_table(conn, "chunks", "id", dims, &mut issues)?;
3359        verify_quantized_table(conn, "messages", "id", dims, &mut issues)?;
3360        verify_quantized_table(conn, "episodes", "episode_id", dims, &mut issues)?;
3361
3362        verify_session_rows(conn, &mut issues)?;
3363        verify_message_rows(conn, &mut issues)?;
3364        verify_fact_rows(conn, &mut issues)?;
3365        verify_document_rows(conn, &mut issues)?;
3366        verify_episode_rows(conn, &mut issues)?;
3367
3368        let integrity_check: String = conn
3369            .query_row("PRAGMA integrity_check", [], |row| row.get(0))
3370            .unwrap_or_else(|_| "error".to_string());
3371        if integrity_check != "ok" {
3372            issues.push(format!("SQLite integrity_check: {}", integrity_check));
3373        }
3374    }
3375
3376    Ok(IntegrityReport {
3377        ok: issues.is_empty(),
3378        schema_version,
3379        fact_count,
3380        chunk_count,
3381        message_count,
3382        facts_missing_embeddings,
3383        chunks_missing_embeddings,
3384        issues,
3385    })
3386}
3387
3388/// Reconcile FTS indexes by rebuilding them from source data.
3389pub fn reconcile_fts(conn: &Connection) -> Result<(), MemoryError> {
3390    with_transaction(conn, |tx| {
3391        tx.execute_batch("DROP TABLE IF EXISTS facts_fts")?;
3392        tx.execute_batch("DELETE FROM facts_rowid_map")?;
3393        tx.execute_batch(
3394            "CREATE VIRTUAL TABLE facts_fts USING fts5(
3395                content,
3396                content='',
3397                content_rowid='rowid',
3398                tokenize='porter unicode61'
3399            )",
3400        )?;
3401        tx.execute_batch("INSERT INTO facts_rowid_map (fact_id) SELECT id FROM facts")?;
3402        tx.execute_batch(
3403            "INSERT INTO facts_fts (rowid, content)
3404             SELECT rm.rowid, f.content
3405             FROM facts_rowid_map rm
3406             JOIN facts f ON f.id = rm.fact_id",
3407        )?;
3408
3409        tx.execute_batch("DROP TABLE IF EXISTS chunks_fts")?;
3410        tx.execute_batch("DELETE FROM chunks_rowid_map")?;
3411        tx.execute_batch(
3412            "CREATE VIRTUAL TABLE chunks_fts USING fts5(
3413                content,
3414                content='',
3415                content_rowid='rowid',
3416                tokenize='porter unicode61'
3417            )",
3418        )?;
3419        tx.execute_batch("INSERT INTO chunks_rowid_map (chunk_id) SELECT id FROM chunks")?;
3420        tx.execute_batch(
3421            "INSERT INTO chunks_fts (rowid, content)
3422             SELECT rm.rowid, c.content
3423             FROM chunks_rowid_map rm
3424             JOIN chunks c ON c.id = rm.chunk_id",
3425        )?;
3426
3427        tx.execute_batch("DROP TABLE IF EXISTS messages_fts")?;
3428        tx.execute_batch("DELETE FROM messages_rowid_map")?;
3429        tx.execute_batch(
3430            "CREATE VIRTUAL TABLE messages_fts USING fts5(
3431                content,
3432                content='',
3433                content_rowid='rowid',
3434                tokenize='porter unicode61'
3435            )",
3436        )?;
3437        tx.execute_batch("INSERT INTO messages_rowid_map (message_id) SELECT id FROM messages")?;
3438        tx.execute_batch(
3439            "INSERT INTO messages_fts (rowid, content)
3440             SELECT rm.rowid, m.content
3441             FROM messages_rowid_map rm
3442             JOIN messages m ON m.id = rm.message_id",
3443        )?;
3444
3445        tx.execute_batch("DROP TABLE IF EXISTS episodes_fts")?;
3446        tx.execute_batch("DELETE FROM episodes_rowid_map")?;
3447        tx.execute_batch(
3448            "CREATE VIRTUAL TABLE episodes_fts USING fts5(
3449                content,
3450                content='',
3451                content_rowid='rowid',
3452                tokenize='porter unicode61'
3453            )",
3454        )?;
3455        tx.execute_batch(
3456            "INSERT INTO episodes_rowid_map (episode_id, document_id) SELECT episode_id, document_id FROM episodes",
3457        )?;
3458        tx.execute_batch(
3459            "INSERT INTO episodes_fts (rowid, content)
3460             SELECT rm.rowid, e.search_text
3461             FROM episodes_rowid_map rm
3462             JOIN episodes e ON e.episode_id = rm.episode_id",
3463        )?;
3464
3465        Ok(())
3466    })?;
3467
3468    tracing::info!("FTS indexes reconciled");
3469    Ok(())
3470}
3471
3472fn verify_fts_drift(
3473    conn: &Connection,
3474    label: &str,
3475    map_table: &str,
3476    source_count: usize,
3477    issues: &mut Vec<String>,
3478) {
3479    let table_exists: bool = conn
3480        .query_row(
3481            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
3482            params![map_table],
3483            |row| row.get(0),
3484        )
3485        .unwrap_or(false);
3486    if !table_exists {
3487        if source_count > 0 {
3488            issues.push(format!("{} rows exist but {} is missing", label, map_table));
3489        }
3490        return;
3491    }
3492
3493    let sql = format!("SELECT COUNT(*) FROM {}", map_table);
3494    let indexed_count: usize = conn.query_row(&sql, [], |row| row.get(0)).unwrap_or(0);
3495    if indexed_count != source_count {
3496        issues.push(format!(
3497            "FTS {} index drift: {} rows in map vs {} source rows",
3498            label, indexed_count, source_count
3499        ));
3500    }
3501}
3502
3503fn verify_blob_table(
3504    conn: &Connection,
3505    table: &'static str,
3506    id_column: &'static str,
3507    blob_column: &'static str,
3508    expected_dims: usize,
3509    issues: &mut Vec<String>,
3510) -> Result<(), MemoryError> {
3511    if expected_dims == 0 {
3512        return Ok(());
3513    }
3514
3515    let sql = format!(
3516        "SELECT CAST({id_column} AS TEXT), {blob_column} FROM {table} WHERE {blob_column} IS NOT NULL"
3517    );
3518    let mut stmt = conn.prepare(&sql)?;
3519    let rows = stmt.query_map([], |row| {
3520        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
3521    })?;
3522
3523    for row in rows {
3524        let (row_id, blob) = row?;
3525        match bytes_to_embedding(&blob) {
3526            Ok(embedding) if embedding.len() != expected_dims => issues.push(format!(
3527                "{}({}) has embedding dimension {} but expected {}",
3528                table,
3529                row_id,
3530                embedding.len(),
3531                expected_dims
3532            )),
3533            Ok(_) => {}
3534            Err(err) => issues.push(format!(
3535                "{}({}) invalid embedding blob: {}",
3536                table, row_id, err
3537            )),
3538        }
3539    }
3540
3541    Ok(())
3542}
3543
3544fn verify_quantized_table(
3545    conn: &Connection,
3546    table: &'static str,
3547    id_column: &'static str,
3548    expected_dims: usize,
3549    issues: &mut Vec<String>,
3550) -> Result<(), MemoryError> {
3551    if expected_dims == 0 {
3552        return Ok(());
3553    }
3554
3555    let sql = format!(
3556        "SELECT CAST({id_column} AS TEXT), embedding_q8 FROM {table} WHERE embedding IS NOT NULL"
3557    );
3558    let mut stmt = conn.prepare(&sql)?;
3559    let rows = stmt.query_map([], |row| {
3560        Ok((row.get::<_, String>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
3561    })?;
3562
3563    for row in rows {
3564        let (row_id, blob) = row?;
3565        match blob {
3566            Some(blob) => {
3567                if let Err(err) = unpack_quantized(&blob, expected_dims) {
3568                    issues.push(format!(
3569                        "{}({}) invalid quantized embedding: {}",
3570                        table, row_id, err
3571                    ));
3572                }
3573            }
3574            None => issues.push(format!("{}({}) missing quantized embedding", table, row_id)),
3575        }
3576    }
3577
3578    Ok(())
3579}
3580
3581fn verify_session_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3582    let mut stmt = conn.prepare("SELECT id, metadata FROM sessions WHERE metadata IS NOT NULL")?;
3583    let rows = stmt.query_map([], |row| {
3584        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3585    })?;
3586    for row in rows {
3587        let (id, metadata) = row?;
3588        if let Err(err) = parse_optional_json("sessions", &id, "metadata", Some(&metadata)) {
3589            issues.push(err.to_string());
3590        }
3591    }
3592    Ok(())
3593}
3594
3595fn verify_message_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3596    let mut stmt = conn.prepare("SELECT id, role, metadata FROM messages")?;
3597    let rows = stmt.query_map([], |row| {
3598        Ok((
3599            row.get::<_, i64>(0)?,
3600            row.get::<_, String>(1)?,
3601            row.get::<_, Option<String>>(2)?,
3602        ))
3603    })?;
3604    for row in rows {
3605        let (id, role, metadata) = row?;
3606        let row_id = id.to_string();
3607        if let Err(err) = parse_role("messages", &row_id, &role) {
3608            issues.push(err.to_string());
3609        }
3610        if let Err(err) = parse_optional_json("messages", &row_id, "metadata", metadata.as_deref())
3611        {
3612            issues.push(err.to_string());
3613        }
3614    }
3615    Ok(())
3616}
3617
3618fn verify_fact_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3619    let mut stmt = conn.prepare("SELECT id, metadata FROM facts WHERE metadata IS NOT NULL")?;
3620    let rows = stmt.query_map([], |row| {
3621        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3622    })?;
3623    for row in rows {
3624        let (id, metadata) = row?;
3625        if let Err(err) = parse_optional_json("facts", &id, "metadata", Some(&metadata)) {
3626            issues.push(err.to_string());
3627        }
3628    }
3629    Ok(())
3630}
3631
3632fn verify_document_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3633    let mut stmt = conn.prepare("SELECT id, metadata FROM documents WHERE metadata IS NOT NULL")?;
3634    let rows = stmt.query_map([], |row| {
3635        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3636    })?;
3637    for row in rows {
3638        let (id, metadata) = row?;
3639        if let Err(err) = parse_optional_json("documents", &id, "metadata", Some(&metadata)) {
3640            issues.push(err.to_string());
3641        }
3642    }
3643    Ok(())
3644}
3645
3646fn verify_episode_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3647    let mut stmt = conn.prepare(
3648        "SELECT episode_id, cause_ids, outcome, verification_status
3649         FROM episodes",
3650    )?;
3651    let rows = stmt.query_map([], |row| {
3652        Ok((
3653            row.get::<_, String>(0)?,
3654            row.get::<_, String>(1)?,
3655            row.get::<_, String>(2)?,
3656            row.get::<_, String>(3)?,
3657        ))
3658    })?;
3659    for row in rows {
3660        let (episode_id, cause_ids, outcome, verification_status) = row?;
3661        if let Err(err) = parse_string_list_json("episodes", &episode_id, "cause_ids", &cause_ids) {
3662            issues.push(err.to_string());
3663        }
3664        if let Err(err) = parse_episode_outcome(&episode_id, &outcome) {
3665            issues.push(err.to_string());
3666        }
3667        if let Err(err) = parse_verification_status(&episode_id, &verification_status) {
3668            issues.push(err.to_string());
3669        }
3670    }
3671    Ok(())
3672}
3673
3674#[derive(Debug, Clone)]
3675pub(crate) struct ProveKvPoolGenerationRow {
3676    pub generation: ProveKvPoolGenerationV1,
3677}
3678
3679#[allow(dead_code)] // retained for provekv pool diagnostics, not currently called
3680fn parse_provekv_status(value: &str) -> ProveKvPoolGenerationStatus {
3681    match value {
3682        "disabled" => ProveKvPoolGenerationStatus::Disabled,
3683        "missing" => ProveKvPoolGenerationStatus::Missing,
3684        "building" => ProveKvPoolGenerationStatus::Building,
3685        "ready" => ProveKvPoolGenerationStatus::Ready,
3686        "stale" => ProveKvPoolGenerationStatus::Stale,
3687        "failed" => ProveKvPoolGenerationStatus::Failed,
3688        _ => ProveKvPoolGenerationStatus::Failed,
3689    }
3690}
3691
3692fn provekv_generation_from_row(
3693    row: &rusqlite::Row<'_>,
3694) -> rusqlite::Result<ProveKvPoolGenerationRow> {
3695    let created_at: String = row.get(9)?;
3696    Ok(ProveKvPoolGenerationRow {
3697        generation: ProveKvPoolGenerationV1 {
3698            schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3699            generation_id: row.get(0)?,
3700            embedding_snapshot_digest: row.get(1)?,
3701            source_digest: row.get(2)?,
3702            pool_manifest_digest: row.get(3)?,
3703            codec_family: row.get(4)?,
3704            codec_profile: row.get(5)?,
3705            vector_dim: row.get::<_, i64>(6)? as usize,
3706            item_count: row.get::<_, i64>(7)? as usize,
3707            payload_bytes: row.get::<_, i64>(8)? as u64,
3708            created_at: DateTime::parse_from_rfc3339(&created_at)
3709                .map(|dt| dt.with_timezone(&Utc))
3710                .map_err(|err| {
3711                    rusqlite::Error::FromSqlConversionFailure(
3712                        9,
3713                        rusqlite::types::Type::Text,
3714                        Box::new(err),
3715                    )
3716                })?,
3717        },
3718    })
3719}
3720
3721#[allow(dead_code)]
3722pub(crate) fn insert_provekv_pool_generation(
3723    conn: &Connection,
3724    generation: &ProveKvPoolGenerationV1,
3725    payload: &[u8],
3726    item_map: &[ProveKvPoolItemMapEntryV1],
3727) -> Result<(), MemoryError> {
3728    let tx = conn.unchecked_transaction()?;
3729    tx.execute(
3730        "INSERT OR REPLACE INTO provekv_pool_generations
3731         (generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3732          codec_family, codec_profile, vector_dim, item_count, payload_bytes, payload,
3733          status, failure_reason, created_at)
3734         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'ready', NULL, ?11)",
3735        params![
3736            generation.generation_id,
3737            generation.embedding_snapshot_digest,
3738            generation.source_digest,
3739            generation.pool_manifest_digest,
3740            generation.codec_family,
3741            generation.codec_profile,
3742            generation.vector_dim as i64,
3743            generation.item_count as i64,
3744            generation.payload_bytes as i64,
3745            payload,
3746            generation.created_at.to_rfc3339(),
3747        ],
3748    )?;
3749    tx.execute(
3750        "DELETE FROM provekv_pool_item_map WHERE generation_id = ?1",
3751        params![generation.generation_id],
3752    )?;
3753    for entry in item_map {
3754        tx.execute(
3755            "INSERT INTO provekv_pool_item_map
3756             (generation_id, item_id, source_type, pool_index, embedding_digest)
3757             VALUES (?1, ?2, ?3, ?4, ?5)",
3758            params![
3759                entry.generation_id,
3760                entry.item_id,
3761                entry.source_type,
3762                entry.pool_index as i64,
3763                entry.embedding_digest,
3764            ],
3765        )?;
3766    }
3767    tx.commit()?;
3768    Ok(())
3769}
3770
3771pub(crate) fn latest_ready_provekv_pool_generation(
3772    conn: &Connection,
3773) -> Result<Option<ProveKvPoolGenerationRow>, MemoryError> {
3774    conn.query_row(
3775        "SELECT generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3776                codec_family, codec_profile, vector_dim, item_count, payload_bytes, created_at
3777         FROM provekv_pool_generations
3778         WHERE status = 'ready'
3779         ORDER BY created_at DESC
3780         LIMIT 1",
3781        [],
3782        provekv_generation_from_row,
3783    )
3784    .optional()
3785    .map_err(MemoryError::from)
3786}
3787
3788pub(crate) fn load_provekv_pool_payload(
3789    conn: &Connection,
3790    generation_id: &str,
3791) -> Result<Vec<u8>, MemoryError> {
3792    conn.query_row(
3793        "SELECT payload FROM provekv_pool_generations WHERE generation_id = ?1",
3794        params![generation_id],
3795        |row| row.get(0),
3796    )
3797    .map_err(MemoryError::from)
3798}
3799
3800pub(crate) fn load_provekv_pool_item_map(
3801    conn: &Connection,
3802    generation_id: &str,
3803) -> Result<Vec<ProveKvPoolItemMapEntryV1>, MemoryError> {
3804    let mut stmt = conn.prepare(
3805        "SELECT generation_id, item_id, source_type, pool_index, embedding_digest
3806         FROM provekv_pool_item_map
3807         WHERE generation_id = ?1
3808         ORDER BY pool_index ASC",
3809    )?;
3810    let rows = stmt.query_map(params![generation_id], |row| {
3811        Ok(ProveKvPoolItemMapEntryV1 {
3812            generation_id: row.get(0)?,
3813            item_id: row.get(1)?,
3814            source_type: row.get(2)?,
3815            pool_index: row.get::<_, i64>(3)? as usize,
3816            embedding_digest: row.get(4)?,
3817        })
3818    })?;
3819    let mut entries = Vec::new();
3820    for row in rows {
3821        entries.push(row?);
3822    }
3823    Ok(entries)
3824}
3825
3826#[allow(dead_code)]
3827pub(crate) fn mark_provekv_pool_generation_failed(
3828    conn: &Connection,
3829    generation_id: &str,
3830    reason: &str,
3831) -> Result<(), MemoryError> {
3832    conn.execute(
3833        "UPDATE provekv_pool_generations SET status = 'failed', failure_reason = ?2 WHERE generation_id = ?1",
3834        params![generation_id, reason],
3835    )?;
3836    Ok(())
3837}
3838
3839#[allow(dead_code)] // retained for provekv pool diagnostics, not currently called
3840pub(crate) fn provekv_pool_artifact_status(
3841    conn: &Connection,
3842) -> Result<ProveKvPoolArtifactStatusV1, MemoryError> {
3843    let row = conn
3844        .query_row(
3845            "SELECT generation_id, embedding_snapshot_digest, pool_manifest_digest,
3846                    item_count, payload_bytes, status, failure_reason
3847             FROM provekv_pool_generations
3848             ORDER BY created_at DESC
3849             LIMIT 1",
3850            [],
3851            |row| {
3852                Ok((
3853                    row.get::<_, String>(0)?,
3854                    row.get::<_, String>(1)?,
3855                    row.get::<_, String>(2)?,
3856                    row.get::<_, i64>(3)?,
3857                    row.get::<_, i64>(4)?,
3858                    row.get::<_, String>(5)?,
3859                    row.get::<_, Option<String>>(6)?,
3860                ))
3861            },
3862        )
3863        .optional()?;
3864    Ok(match row {
3865        Some((
3866            generation_id,
3867            snapshot_digest,
3868            manifest_digest,
3869            item_count,
3870            payload_bytes,
3871            status,
3872            reason,
3873        )) => ProveKvPoolArtifactStatusV1 {
3874            status: parse_provekv_status(&status),
3875            generation_id: Some(generation_id),
3876            embedding_snapshot_digest: Some(snapshot_digest),
3877            pool_manifest_digest: Some(manifest_digest),
3878            item_count: item_count as usize,
3879            payload_bytes: payload_bytes as u64,
3880            reason,
3881        },
3882        None => ProveKvPoolArtifactStatusV1 {
3883            status: ProveKvPoolGenerationStatus::Missing,
3884            generation_id: None,
3885            embedding_snapshot_digest: None,
3886            pool_manifest_digest: None,
3887            item_count: 0,
3888            payload_bytes: 0,
3889            reason: Some("provekv_pool_generation_not_materialized".into()),
3890        },
3891    })
3892}
3893
3894#[cfg(test)]
3895mod provekv_pool_generation_db_tests {
3896    use super::*;
3897
3898    fn test_conn() -> Connection {
3899        let conn = Connection::open_in_memory().expect("in-memory db opens");
3900        conn.execute_batch(MIGRATION_V24)
3901            .expect("proveKV schema migration applies");
3902        conn
3903    }
3904
3905    fn generation(id: &str) -> ProveKvPoolGenerationV1 {
3906        ProveKvPoolGenerationV1 {
3907            schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3908            generation_id: id.to_string(),
3909            embedding_snapshot_digest: "blake3:snapshot".to_string(),
3910            source_digest: "blake3:source".to_string(),
3911            pool_manifest_digest: "blake3:manifest".to_string(),
3912            codec_family: "provekv_pool".to_string(),
3913            codec_profile: "semantic-memory-f32-derived-candidate-v1".to_string(),
3914            vector_dim: 4,
3915            item_count: 2,
3916            payload_bytes: 3,
3917            created_at: Utc::now(),
3918        }
3919    }
3920
3921    #[test]
3922    fn provekv_pool_generation_roundtrips_and_cascades_item_map() {
3923        let conn = test_conn();
3924        let gen = generation("gen-1");
3925        let item_map = vec![
3926            ProveKvPoolItemMapEntryV1 {
3927                generation_id: gen.generation_id.clone(),
3928                item_id: "fact-1".to_string(),
3929                source_type: "fact".to_string(),
3930                pool_index: 0,
3931                embedding_digest: "blake3:item-1".to_string(),
3932            },
3933            ProveKvPoolItemMapEntryV1 {
3934                generation_id: gen.generation_id.clone(),
3935                item_id: "fact-2".to_string(),
3936                source_type: "fact".to_string(),
3937                pool_index: 1,
3938                embedding_digest: "blake3:item-2".to_string(),
3939            },
3940        ];
3941        insert_provekv_pool_generation(&conn, &gen, &[1, 2, 3], &item_map).unwrap();
3942
3943        let latest = latest_ready_provekv_pool_generation(&conn)
3944            .unwrap()
3945            .expect("latest ready generation");
3946        assert_eq!(latest.generation.generation_id, gen.generation_id);
3947        assert_eq!(
3948            load_provekv_pool_payload(&conn, "gen-1").unwrap(),
3949            vec![1, 2, 3]
3950        );
3951        assert_eq!(
3952            load_provekv_pool_item_map(&conn, "gen-1").unwrap(),
3953            item_map
3954        );
3955
3956        mark_provekv_pool_generation_failed(&conn, "gen-1", "boom").unwrap();
3957        let status = provekv_pool_artifact_status(&conn).unwrap();
3958        assert_eq!(status.status, ProveKvPoolGenerationStatus::Failed);
3959        assert_eq!(status.reason.as_deref(), Some("boom"));
3960
3961        conn.execute(
3962            "DELETE FROM provekv_pool_generations WHERE generation_id = 'gen-1'",
3963            [],
3964        )
3965        .unwrap();
3966        let count: i64 = conn
3967            .query_row("SELECT COUNT(*) FROM provekv_pool_item_map", [], |row| {
3968                row.get(0)
3969            })
3970            .unwrap();
3971        assert_eq!(count, 0);
3972    }
3973}