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];
931
932/// Maximum schema version this build supports.
933pub const MAX_SCHEMA_VERSION: u32 = 36;
934
935/// Procedural migration for V9: rebuild episodes table with episode_id PK.
936fn run_migration_v9(conn: &Connection) -> Result<(), MemoryError> {
937    // Check if episodes table exists (fresh DBs won't have it yet at V6)
938    let episodes_exist: bool = conn
939        .query_row(
940            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='episodes'",
941            [],
942            |row| row.get(0),
943        )
944        .map_err(|e| MemoryError::MigrationFailed {
945            version: 9,
946            reason: format!("existence check failed: {e}"),
947        })?;
948
949    if !episodes_exist {
950        // No episodes table to migrate; create the target schema directly
951        conn.execute_batch(
952            "CREATE TABLE IF NOT EXISTS episode_causes (
953                 episode_id    TEXT NOT NULL,
954                 cause_node_id TEXT NOT NULL,
955                 ordinal       INTEGER NOT NULL DEFAULT 0,
956                 PRIMARY KEY (episode_id, cause_node_id)
957             );
958             CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
959        )?;
960        return Ok(());
961    }
962
963    // Disable foreign keys for table rebuild
964    conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
965
966    conn.execute_batch(
967        "CREATE TABLE episodes_new (
968             episode_id  TEXT PRIMARY KEY,
969             document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
970             cause_ids   TEXT NOT NULL,
971             effect_type TEXT NOT NULL,
972             outcome     TEXT NOT NULL DEFAULT 'pending',
973             confidence  REAL NOT NULL DEFAULT 0.0,
974             verification_status TEXT NOT NULL DEFAULT '{\"status\":\"unverified\"}',
975             experiment_id TEXT,
976             created_at  TEXT NOT NULL DEFAULT (datetime('now')),
977             updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
978             search_text TEXT NOT NULL DEFAULT '',
979             embedding   BLOB,
980             embedding_q8 BLOB,
981             trace_id    TEXT
982         )",
983    )?;
984
985    // Migrate existing data with deterministic episode_id
986    conn.execute_batch(
987        "INSERT INTO episodes_new
988             (episode_id, document_id, cause_ids, effect_type, outcome, confidence,
989              verification_status, experiment_id, created_at, updated_at,
990              search_text, embedding, embedding_q8, trace_id)
991         SELECT
992             document_id || '-ep0',
993             document_id, cause_ids, effect_type, outcome, confidence,
994             verification_status, experiment_id, created_at, updated_at,
995             search_text, embedding, embedding_q8, trace_id
996         FROM episodes",
997    )?;
998
999    conn.execute_batch("DROP TABLE episodes")?;
1000    conn.execute_batch("ALTER TABLE episodes_new RENAME TO episodes")?;
1001
1002    conn.execute_batch(
1003        "CREATE INDEX idx_episodes_document_id ON episodes(document_id);
1004         CREATE INDEX idx_episodes_effect_type ON episodes(effect_type);
1005         CREATE INDEX idx_episodes_outcome ON episodes(outcome);
1006         CREATE INDEX idx_episodes_experiment_id ON episodes(experiment_id);",
1007    )?;
1008
1009    // Rebuild episodes_rowid_map with episode_id
1010    conn.execute_batch(
1011        "DROP TABLE IF EXISTS episodes_rowid_map;
1012         CREATE TABLE episodes_rowid_map (
1013             rowid       INTEGER PRIMARY KEY AUTOINCREMENT,
1014             episode_id  TEXT NOT NULL UNIQUE,
1015             document_id TEXT
1016         );
1017         INSERT INTO episodes_rowid_map (episode_id, document_id)
1018         SELECT episode_id, document_id FROM episodes;",
1019    )?;
1020
1021    // Rebuild episodes FTS
1022    conn.execute_batch(
1023        "DROP TABLE IF EXISTS episodes_fts;
1024         CREATE VIRTUAL TABLE episodes_fts USING fts5(
1025             content,
1026             content='',
1027             content_rowid='rowid',
1028             tokenize='porter unicode61'
1029         );
1030         INSERT INTO episodes_fts (rowid, content)
1031         SELECT rm.rowid, e.search_text
1032         FROM episodes_rowid_map rm
1033         JOIN episodes e ON e.episode_id = rm.episode_id;",
1034    )?;
1035
1036    // Normalized causal edge table
1037    conn.execute_batch(
1038        "CREATE TABLE IF NOT EXISTS episode_causes (
1039             episode_id    TEXT NOT NULL,
1040             cause_node_id TEXT NOT NULL,
1041             ordinal       INTEGER NOT NULL DEFAULT 0,
1042             PRIMARY KEY (episode_id, cause_node_id)
1043         );
1044         CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
1045    )?;
1046
1047    // Populate edge table from existing JSON cause_ids
1048    conn.execute_batch(
1049        "INSERT OR IGNORE INTO episode_causes (episode_id, cause_node_id, ordinal)
1050         SELECT e.episode_id, je.value, CAST(je.key AS INTEGER)
1051         FROM episodes e, json_each(e.cause_ids) je;",
1052    )?;
1053
1054    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
1055
1056    Ok(())
1057}
1058
1059/// How thorough the integrity check should be.
1060#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1061pub enum VerifyMode {
1062    /// Quick: counts and basic metadata only.
1063    Quick,
1064    /// Full: includes FTS, JSON/enum decoding, blobs, and SQLite integrity_check.
1065    Full,
1066}
1067
1068/// Result of an integrity verification.
1069#[derive(Debug, Clone)]
1070pub struct IntegrityReport {
1071    pub ok: bool,
1072    pub schema_version: u32,
1073    pub fact_count: usize,
1074    pub chunk_count: usize,
1075    pub message_count: usize,
1076    pub facts_missing_embeddings: usize,
1077    pub chunks_missing_embeddings: usize,
1078    pub issues: Vec<String>,
1079}
1080
1081/// Action to take when integrity issues are found.
1082#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1083pub enum ReconcileAction {
1084    ReportOnly,
1085    RebuildFts,
1086    ReEmbed,
1087}
1088
1089/// Desired HNSW sidecar mutation queued in SQLite.
1090#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1091pub(crate) enum IndexOpKind {
1092    Upsert,
1093    Delete,
1094}
1095
1096impl IndexOpKind {
1097    pub(crate) fn as_str(self) -> &'static str {
1098        match self {
1099            Self::Upsert => "upsert",
1100            Self::Delete => "delete",
1101        }
1102    }
1103
1104    fn parse(raw: &str, item_key: &str) -> Result<Self, MemoryError> {
1105        match raw {
1106            "upsert" => Ok(Self::Upsert),
1107            "delete" => Ok(Self::Delete),
1108            other => Err(MemoryError::CorruptData {
1109                table: "pending_index_ops",
1110                row_id: item_key.to_string(),
1111                detail: format!("invalid op_kind '{other}'"),
1112            }),
1113        }
1114    }
1115}
1116
1117/// Durable sidecar repair record.
1118#[derive(Debug, Clone)]
1119pub(crate) struct PendingIndexOp {
1120    pub item_key: String,
1121    pub entity_type: String,
1122    pub op_kind: IndexOpKind,
1123    pub attempt_count: u32,
1124    pub last_error: Option<String>,
1125}
1126
1127/// Run a closure inside an unchecked transaction, committing on success.
1128pub fn with_transaction<F, T>(conn: &Connection, f: F) -> Result<T, MemoryError>
1129where
1130    F: FnOnce(&rusqlite::Transaction<'_>) -> Result<T, MemoryError>,
1131{
1132    let tx = conn.unchecked_transaction()?;
1133    let result = f(&tx)?;
1134    tx.commit()?;
1135    Ok(result)
1136}
1137
1138/// Open or create a SQLite database, configure pragmas, and run migrations.
1139pub fn open_database(
1140    path: &Path,
1141    pool: &PoolConfig,
1142    limits: &MemoryLimits,
1143) -> Result<Connection, MemoryError> {
1144    open_database_internal(path, pool, limits.max_db_size_bytes, true)
1145}
1146
1147/// Open a SQLite connection with pragmas applied but without running migrations.
1148#[allow(dead_code)] // public API — used by external consumers, not internally
1149pub fn open_database_connection(
1150    path: &Path,
1151    pool: &PoolConfig,
1152    limits: &MemoryLimits,
1153) -> Result<Connection, MemoryError> {
1154    open_database_internal(path, pool, limits.max_db_size_bytes, false)
1155}
1156
1157pub(crate) fn open_database_internal(
1158    path: &Path,
1159    pool: &PoolConfig,
1160    max_db_size_bytes: u64,
1161    run_schema_migrations: bool,
1162) -> Result<Connection, MemoryError> {
1163    create_parent_dirs(path)?;
1164    let conn = Connection::open(path)?;
1165    configure_connection(&conn, path, pool, max_db_size_bytes, false)?;
1166    if run_schema_migrations {
1167        run_migrations(&conn)?;
1168    }
1169    Ok(conn)
1170}
1171
1172pub(crate) fn open_pool_member_connection(
1173    path: &Path,
1174    pool: &PoolConfig,
1175    limits: &MemoryLimits,
1176    query_only: bool,
1177) -> Result<Connection, MemoryError> {
1178    create_parent_dirs(path)?;
1179    let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE;
1180    let conn = Connection::open_with_flags(path, flags)?;
1181    configure_connection(&conn, path, pool, limits.max_db_size_bytes, query_only)?;
1182    Ok(conn)
1183}
1184
1185fn create_parent_dirs(path: &Path) -> Result<(), MemoryError> {
1186    if let Some(parent) = path.parent() {
1187        if !parent.as_os_str().is_empty() {
1188            std::fs::create_dir_all(parent).map_err(|e| {
1189                MemoryError::StorageError(format!(
1190                    "failed to create database directory {}: {}",
1191                    parent.display(),
1192                    e
1193                ))
1194            })?;
1195        }
1196    }
1197    Ok(())
1198}
1199
1200fn configure_connection(
1201    conn: &Connection,
1202    path: &Path,
1203    pool: &PoolConfig,
1204    max_db_size_bytes: u64,
1205    query_only: bool,
1206) -> Result<(), MemoryError> {
1207    let journal_mode = if pool.enable_wal { "WAL" } else { "DELETE" };
1208    conn.execute_batch(&format!(
1209        "PRAGMA journal_mode = {};
1210         PRAGMA foreign_keys = ON;
1211         PRAGMA busy_timeout = {};
1212         PRAGMA synchronous = NORMAL;
1213         PRAGMA temp_store = MEMORY;
1214         PRAGMA wal_autocheckpoint = {};
1215         PRAGMA cache_size = -25600;
1216         PRAGMA mmap_size = 268435456;",
1217        journal_mode, pool.busy_timeout_ms, pool.wal_autocheckpoint,
1218    ))?;
1219
1220    if query_only {
1221        conn.execute_batch("PRAGMA query_only = ON;")?;
1222    }
1223
1224    let actual_journal_mode: String =
1225        conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
1226    let expected_journal_mode = if pool.enable_wal { "wal" } else { "delete" };
1227    if actual_journal_mode.to_lowercase() != expected_journal_mode {
1228        return Err(MemoryError::StorageError(format!(
1229            "SQLite journal mode mismatch for {}: requested {}, got {}",
1230            path.display(),
1231            expected_journal_mode,
1232            actual_journal_mode
1233        )));
1234    }
1235
1236    if max_db_size_bytes > 0 {
1237        let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
1238        let max_page_count = max_db_size_bytes.div_ceil(page_size);
1239
1240        // SM-AUD-0065: Validate max_page_count before setting pragma
1241        const MAX_SQLITE_PAGE_COUNT: u64 = 1_073_741_823; // SQLite hard limit
1242        const MIN_SQLITE_PAGE_COUNT: u64 = 1;
1243        if !(MIN_SQLITE_PAGE_COUNT..=MAX_SQLITE_PAGE_COUNT).contains(&max_page_count) {
1244            return Err(MemoryError::StorageError(format!(
1245                "Invalid max_page_count {}: must be between {} and {}",
1246                max_page_count, MIN_SQLITE_PAGE_COUNT, MAX_SQLITE_PAGE_COUNT
1247            )));
1248        }
1249
1250        let actual_max_page_count: u64 = conn.query_row(
1251            &format!("PRAGMA max_page_count = {}", max_page_count),
1252            [],
1253            |row| row.get(0),
1254        )?;
1255        let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
1256
1257        if page_count > actual_max_page_count {
1258            return Err(MemoryError::DatabaseSizeLimitExceeded {
1259                current: page_count.saturating_mul(page_size),
1260                limit: max_db_size_bytes,
1261            });
1262        }
1263    }
1264
1265    // SM-AUD-0064: Assert foreign_keys is ON after configuration
1266    let foreign_keys_enabled: bool = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
1267    if !foreign_keys_enabled {
1268        return Err(MemoryError::StorageError(
1269            "PRAGMA foreign_keys failed to enable after configuration".to_string(),
1270        ));
1271    }
1272
1273    Ok(())
1274}
1275
1276/// Run all pending migrations.
1277pub fn run_migrations(conn: &Connection) -> Result<(), MemoryError> {
1278    let user_version: u32 = conn
1279        .query_row("PRAGMA user_version", [], |row| row.get(0))
1280        .map_err(|e| MemoryError::MigrationFailed {
1281            version: 0,
1282            reason: format!("failed to read PRAGMA user_version: {e}"),
1283        })?;
1284
1285    if user_version > MAX_SCHEMA_VERSION {
1286        return Err(MemoryError::SchemaAhead {
1287            found: user_version,
1288            supported: MAX_SCHEMA_VERSION,
1289        });
1290    }
1291
1292    conn.execute_batch(
1293        "CREATE TABLE IF NOT EXISTS _schema_version (
1294            version     INTEGER PRIMARY KEY,
1295            applied_at  TEXT NOT NULL DEFAULT (datetime('now'))
1296        );",
1297    )?;
1298
1299    for &(version, sql) in MIGRATIONS {
1300        let current_version: u32 = conn
1301            .query_row(
1302                "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1303                [],
1304                |row| row.get(0),
1305            )
1306            .unwrap_or(0);
1307
1308        if current_version >= version {
1309            continue;
1310        }
1311
1312        with_transaction(conn, |tx| {
1313            match version {
1314                9 => run_migration_v9(tx).map_err(|e| MemoryError::MigrationFailed {
1315                    version,
1316                    reason: e.to_string(),
1317                })?,
1318                16 => run_migration_v16(tx).map_err(|e| MemoryError::MigrationFailed {
1319                    version,
1320                    reason: e.to_string(),
1321                })?,
1322                17 => run_migration_v17(tx).map_err(|e| MemoryError::MigrationFailed {
1323                    version,
1324                    reason: e.to_string(),
1325                })?,
1326                20 => run_migration_v20(tx).map_err(|e| MemoryError::MigrationFailed {
1327                    version,
1328                    reason: e.to_string(),
1329                })?,
1330                21 => run_migration_v21(tx).map_err(|e| MemoryError::MigrationFailed {
1331                    version,
1332                    reason: e.to_string(),
1333                })?,
1334                26 => run_migration_v26(tx).map_err(|e| MemoryError::MigrationFailed {
1335                    version,
1336                    reason: e.to_string(),
1337                })?,
1338                28 => run_migration_v28(tx).map_err(|e| MemoryError::MigrationFailed {
1339                    version,
1340                    reason: e.to_string(),
1341                })?,
1342                _ => tx
1343                    .execute_batch(sql)
1344                    .map_err(|e| MemoryError::MigrationFailed {
1345                        version,
1346                        reason: e.to_string(),
1347                    })?,
1348            }
1349            tx.execute(
1350                "INSERT INTO _schema_version (version) VALUES (?1)",
1351                params![version],
1352            )
1353            .map_err(|e| MemoryError::MigrationFailed {
1354                version,
1355                reason: e.to_string(),
1356            })?;
1357            Ok(())
1358        })?;
1359
1360        tracing::info!("Applied migration V{}", version);
1361    }
1362
1363    // Keep the authority surface self-healing if a database carries the V29
1364    // marker but one of its idempotent tables was removed by an interrupted
1365    // setup or an older repair tool.
1366    with_transaction(conn, |tx| {
1367        tx.execute_batch(MIGRATION_V29)
1368            .map_err(MemoryError::Database)
1369    })?;
1370    with_transaction(conn, |tx| {
1371        tx.execute_batch(MIGRATION_V30)
1372            .map_err(MemoryError::Database)
1373    })?;
1374    with_transaction(conn, |tx| {
1375        tx.execute_batch(MIGRATION_V31)
1376            .map_err(MemoryError::Database)
1377    })?;
1378
1379    let final_version: u32 = conn
1380        .query_row(
1381            "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1382            [],
1383            |row| row.get(0),
1384        )
1385        .unwrap_or(0);
1386    conn.execute_batch(&format!("PRAGMA user_version = {};", final_version))?;
1387
1388    Ok(())
1389}
1390
1391fn run_migration_v16(conn: &Connection) -> Result<(), rusqlite::Error> {
1392    add_column_if_missing(conn, "projection_import_log", "kernel_payload_json", "TEXT")?;
1393    add_column_if_missing(
1394        conn,
1395        "projection_import_failures",
1396        "kernel_payload_json",
1397        "TEXT",
1398    )?;
1399    Ok(())
1400}
1401
1402fn run_migration_v17(conn: &Connection) -> Result<(), rusqlite::Error> {
1403    add_column_if_missing(conn, "projection_import_log", "episode_bundle_id", "TEXT")?;
1404    add_column_if_missing(conn, "projection_import_log", "episode_bundle_json", "TEXT")?;
1405    add_column_if_missing(
1406        conn,
1407        "projection_import_log",
1408        "execution_context_json",
1409        "TEXT",
1410    )?;
1411    add_column_if_missing(
1412        conn,
1413        "projection_import_failures",
1414        "episode_bundle_id",
1415        "TEXT",
1416    )?;
1417    add_column_if_missing(
1418        conn,
1419        "projection_import_failures",
1420        "episode_bundle_json",
1421        "TEXT",
1422    )?;
1423    add_column_if_missing(
1424        conn,
1425        "projection_import_failures",
1426        "execution_context_json",
1427        "TEXT",
1428    )?;
1429    Ok(())
1430}
1431
1432fn run_migration_v20(conn: &Connection) -> Result<(), rusqlite::Error> {
1433    add_column_if_missing(conn, "derived_vector_artifacts", "encoded_digest", "TEXT")?;
1434    conn.execute(
1435        "UPDATE derived_vector_artifacts
1436         SET encoded_digest = artifact_digest
1437         WHERE encoded_digest IS NULL OR encoded_digest = ''",
1438        [],
1439    )?;
1440    add_column_if_missing(
1441        conn,
1442        "derived_vector_artifacts",
1443        "encoding",
1444        "TEXT NOT NULL DEFAULT 'turbo_code_wire_v1'",
1445    )?;
1446    add_column_if_missing(
1447        conn,
1448        "derived_vector_artifacts",
1449        "dim",
1450        "INTEGER NOT NULL DEFAULT 0",
1451    )?;
1452    add_column_if_missing(
1453        conn,
1454        "derived_vector_artifacts",
1455        "status",
1456        "TEXT NOT NULL DEFAULT 'active'",
1457    )?;
1458    conn.execute_batch(
1459        "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
1460         ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
1461         CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
1462         ON derived_vector_artifacts(source_embedding_digest);",
1463    )?;
1464    Ok(())
1465}
1466
1467fn run_migration_v21(conn: &Connection) -> Result<(), rusqlite::Error> {
1468    conn.execute_batch(MIGRATION_V21)?;
1469    add_column_if_missing(conn, "derived_vector_artifacts", "generation_id", "TEXT")?;
1470    conn.execute_batch(
1471        "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_generation
1472         ON derived_vector_artifacts(generation_id, status);",
1473    )?;
1474    Ok(())
1475}
1476
1477const SEARCH_RECEIPT_SCHEMA_VERSION: &str = "vector_search_receipt_v1";
1478
1479#[derive(Debug, Serialize, Deserialize)]
1480struct StoredVectorSearchReceiptV1 {
1481    #[serde(default = "default_search_receipt_schema_version")]
1482    schema_version: String,
1483    receipt_id: String,
1484    evaluation_time: DateTime<Utc>,
1485    #[serde(default)]
1486    receipt_digest: Option<String>,
1487    #[serde(default)]
1488    trace_id: Option<String>,
1489    #[serde(default)]
1490    attempt_family_id: Option<String>,
1491    #[serde(default)]
1492    attempt_id: Option<String>,
1493    #[serde(default)]
1494    replay_of: Option<String>,
1495    query_embedding_digest: Option<String>,
1496    #[serde(default)]
1497    query_text_digest: Option<String>,
1498    #[serde(default)]
1499    query_input_digest: Option<String>,
1500    #[serde(default)]
1501    filter_digest: Option<String>,
1502    #[serde(default)]
1503    redaction_state: Option<String>,
1504    #[serde(default)]
1505    budget_id: Option<String>,
1506    #[serde(default)]
1507    deadline_at: Option<DateTime<Utc>>,
1508    search_profile: String,
1509    candidate_backend: String,
1510    codec_family: Option<String>,
1511    codec_profile_digest: Option<String>,
1512    #[serde(default)]
1513    artifact_profile_digest: Option<String>,
1514    #[serde(default)]
1515    artifact_count: Option<u64>,
1516    #[serde(default)]
1517    artifact_corruption_count: Option<u64>,
1518    #[serde(default)]
1519    artifact_missing_count: Option<u64>,
1520    #[serde(default)]
1521    vector_artifact_manifest_digest: Option<String>,
1522    #[serde(default)]
1523    artifact_generation_id: Option<String>,
1524    #[serde(default)]
1525    approximate_scanned_count: Option<u64>,
1526    #[serde(default)]
1527    approximate_returned_count: Option<u64>,
1528    #[serde(default)]
1529    raw_rows_loaded_count: Option<u64>,
1530    #[serde(default)]
1531    filter_strategy: Option<String>,
1532    #[serde(default)]
1533    vector_artifact_count: Option<u64>,
1534    #[serde(default)]
1535    vector_artifact_missing_count: Option<u64>,
1536    #[serde(default)]
1537    vector_artifact_stale_count: Option<u64>,
1538    #[serde(default)]
1539    exact_rerank_count: Option<u64>,
1540    #[serde(default)]
1541    approximate_candidate_count: Option<u64>,
1542    #[serde(default)]
1543    fallback_reason: Option<String>,
1544    approximate: bool,
1545    requested_candidates: u64,
1546    returned_candidates: u64,
1547    post_filter_candidates: u64,
1548    #[serde(default)]
1549    sparse_enabled: bool,
1550    #[serde(default)]
1551    sparse_weight: Option<f64>,
1552    #[serde(default)]
1553    sparse_query_nonzero_count: Option<u64>,
1554    #[serde(default)]
1555    sparse_candidate_count: Option<u64>,
1556    #[serde(default)]
1557    sparse_representations: Vec<String>,
1558    #[serde(default)]
1559    sparse_result_ranks: Vec<SparseRankReceiptV1>,
1560    fallback: Option<String>,
1561    exact_rerank: bool,
1562    result_ids: Vec<String>,
1563    degradations: Vec<String>,
1564}
1565
1566fn default_search_receipt_schema_version() -> String {
1567    SEARCH_RECEIPT_SCHEMA_VERSION.to_string()
1568}
1569
1570fn b3_digest(bytes: &[u8]) -> String {
1571    format!("blake3:{}", ContentDigest::compute(bytes).hex())
1572}
1573
1574/// Row from the derived vector artifact store.
1575#[cfg(feature = "turbo-quant-codec")]
1576#[derive(Debug, Clone)]
1577pub(crate) struct DerivedVectorArtifactRow {
1578    pub item_key: String,
1579    pub generation_id: Option<String>,
1580    pub codec_family: String,
1581    pub codec_profile_digest: String,
1582    pub source_embedding_digest: String,
1583    pub encoded_digest: String,
1584    pub encoding: String,
1585    pub dim: usize,
1586    pub status: String,
1587    pub encoded: Vec<u8>,
1588    // Codec governance columns (V23 migration)
1589    pub codec_governance_receipt_id: Option<String>,
1590    pub codec_profile: Option<String>,
1591    pub degradation_budget: Option<f64>,
1592    pub raw_source_artifact_id: Option<String>,
1593}
1594
1595/// Active derived vector artifact generation row.
1596#[cfg(feature = "turbo-quant-codec")]
1597#[derive(Debug, Clone)]
1598#[allow(dead_code)]
1599pub(crate) struct DerivedVectorArtifactGenerationRow {
1600    pub generation_id: String,
1601    pub codec_family: String,
1602    pub codec_profile_digest: String,
1603    pub source_snapshot_digest: String,
1604    pub source_row_count: usize,
1605    pub artifact_count: usize,
1606    pub dim: usize,
1607    pub encoding: String,
1608    pub artifact_manifest_digest: String,
1609    pub status: String,
1610}
1611
1612/// Stable digest for an authoritative raw f32 embedding BLOB.
1613#[cfg(feature = "turbo-quant-codec")]
1614pub(crate) fn source_embedding_digest(
1615    blob: &[u8],
1616    expected_dim: usize,
1617) -> Result<String, MemoryError> {
1618    validate_vector_blob_len(blob, expected_dim)?;
1619    let mut builder = DigestBuilder::new();
1620    builder
1621        .update_str("semantic-memory.source_embedding.v1")
1622        .separator()
1623        .update(&(expected_dim as u64).to_le_bytes())
1624        .separator()
1625        .update(blob);
1626    Ok(format!("blake3:{}", builder.finalize().hex()))
1627}
1628
1629#[cfg(feature = "turbo-quant-codec")]
1630fn source_snapshot_digest(rows: &[DerivedVectorArtifactRow], dim: usize) -> String {
1631    let mut entries = rows
1632        .iter()
1633        .map(|row| (row.item_key.as_str(), row.source_embedding_digest.as_str()))
1634        .collect::<Vec<_>>();
1635    entries.sort_unstable();
1636
1637    let mut builder = DigestBuilder::new();
1638    builder
1639        .update_str("semantic-memory.vector_source_snapshot.v1")
1640        .separator()
1641        .update(&(dim as u64).to_le_bytes())
1642        .separator();
1643    for (item_key, source_embedding_digest) in entries {
1644        builder
1645            .update_str(item_key)
1646            .separator()
1647            .update_str(source_embedding_digest)
1648            .separator();
1649    }
1650    format!("blake3:{}", builder.finalize().hex())
1651}
1652
1653#[cfg(feature = "turbo-quant-codec")]
1654pub(crate) fn current_source_snapshot_digest(
1655    conn: &Connection,
1656    dim: usize,
1657) -> Result<(String, usize), MemoryError> {
1658    let mut stmt = conn.prepare(
1659        "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1660         UNION ALL
1661         SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1662         UNION ALL
1663         SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1664         UNION ALL
1665         SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1666    )?;
1667    let rows = stmt.query_map([], |row| {
1668        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1669    })?;
1670
1671    let mut entries = Vec::new();
1672    for row in rows {
1673        let (item_key, blob) = row?;
1674        entries.push((item_key, source_embedding_digest(&blob, dim)?));
1675    }
1676    entries.sort_unstable();
1677
1678    let mut builder = DigestBuilder::new();
1679    builder
1680        .update_str("semantic-memory.vector_source_snapshot.v1")
1681        .separator()
1682        .update(&(dim as u64).to_le_bytes())
1683        .separator();
1684    for (item_key, source_embedding_digest) in &entries {
1685        builder
1686            .update_str(item_key)
1687            .separator()
1688            .update_str(source_embedding_digest)
1689            .separator();
1690    }
1691    Ok((
1692        format!("blake3:{}", builder.finalize().hex()),
1693        entries.len(),
1694    ))
1695}
1696
1697#[cfg(feature = "turbo-quant-codec")]
1698fn derived_artifact_manifest_digest(rows: &[DerivedVectorArtifactRow]) -> String {
1699    let mut entries = rows
1700        .iter()
1701        .map(|row| {
1702            (
1703                row.item_key.as_str(),
1704                row.source_embedding_digest.as_str(),
1705                row.encoded_digest.as_str(),
1706            )
1707        })
1708        .collect::<Vec<_>>();
1709    entries.sort_unstable();
1710
1711    let mut builder = DigestBuilder::new();
1712    builder
1713        .update_str("semantic-memory.vector_artifact_manifest.v1")
1714        .separator();
1715    for (item_key, source_embedding_digest, encoded_digest) in entries {
1716        builder
1717            .update_str(item_key)
1718            .separator()
1719            .update_str(source_embedding_digest)
1720            .separator()
1721            .update_str(encoded_digest)
1722            .separator();
1723    }
1724    format!("blake3:{}", builder.finalize().hex())
1725}
1726
1727#[cfg(feature = "turbo-quant-codec")]
1728pub(crate) fn upsert_derived_vector_artifact(
1729    conn: &Connection,
1730    row: &DerivedVectorArtifactRow,
1731) -> Result<(), MemoryError> {
1732    conn.execute(
1733        "INSERT OR REPLACE INTO derived_vector_artifacts
1734             (item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1735              encoded_digest, artifact_digest, encoding, dim, encoded, created_at, status,
1736              codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id)
1737         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8, ?9, datetime('now'), ?10, ?11, ?12, ?13, ?14)",
1738        params![
1739            row.item_key,
1740            row.generation_id.as_deref(),
1741            row.codec_family,
1742            row.codec_profile_digest,
1743            row.source_embedding_digest,
1744            row.encoded_digest,
1745            row.encoding,
1746            i64::try_from(row.dim)
1747                .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1748            row.encoded,
1749            row.status,
1750            row.codec_governance_receipt_id.as_deref(),
1751            row.codec_profile.as_deref(),
1752            row.degradation_budget,
1753            row.raw_source_artifact_id.as_deref(),
1754        ],
1755    )?;
1756    Ok(())
1757}
1758
1759#[allow(dead_code)] // public API — used by external consumers, not internally
1760pub fn delete_derived_vector_artifact(
1761    conn: &Connection,
1762    item_key: &str,
1763) -> Result<(), MemoryError> {
1764    conn.execute(
1765        "DELETE FROM derived_vector_artifacts WHERE item_key = ?1",
1766        params![item_key],
1767    )?;
1768    Ok(())
1769}
1770
1771pub fn invalidate_derived_vector_artifact(
1772    conn: &Connection,
1773    item_key: &str,
1774) -> Result<(), MemoryError> {
1775    conn.execute(
1776        "UPDATE derived_vector_artifacts
1777         SET status = 'invalidated'
1778         WHERE item_key = ?1 AND status = 'active'",
1779        params![item_key],
1780    )?;
1781    conn.execute(
1782        "UPDATE derived_vector_artifact_generations
1783         SET status = 'invalidated'
1784         WHERE status = 'active'",
1785        [],
1786    )?;
1787    Ok(())
1788}
1789
1790#[cfg(feature = "turbo-quant-codec")]
1791#[allow(dead_code)]
1792pub(crate) fn load_derived_vector_artifacts_by_profile(
1793    conn: &Connection,
1794    codec_family: &str,
1795    codec_profile_digest: &str,
1796) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1797    let mut stmt = conn.prepare(
1798        "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1799                encoded_digest, encoding, dim, status, encoded,
1800                codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1801         FROM derived_vector_artifacts
1802         WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1803    )?;
1804    let rows = stmt.query_map(params![codec_family, codec_profile_digest], |row| {
1805        let dim_i64: i64 = row.get(7)?;
1806        Ok(DerivedVectorArtifactRow {
1807            item_key: row.get(0)?,
1808            generation_id: row.get(1)?,
1809            codec_family: row.get(2)?,
1810            codec_profile_digest: row.get(3)?,
1811            source_embedding_digest: row.get(4)?,
1812            encoded_digest: row.get(5)?,
1813            encoding: row.get(6)?,
1814            dim: usize::try_from(dim_i64).map_err(|err| {
1815                rusqlite::Error::FromSqlConversionFailure(
1816                    7,
1817                    rusqlite::types::Type::Integer,
1818                    Box::new(err),
1819                )
1820            })?,
1821            status: row.get(8)?,
1822            encoded: row.get(9)?,
1823            codec_governance_receipt_id: row.get(10)?,
1824            codec_profile: row.get(11)?,
1825            degradation_budget: row.get(12)?,
1826            raw_source_artifact_id: row.get(13)?,
1827        })
1828    })?;
1829
1830    let mut artifacts = Vec::new();
1831    for row in rows {
1832        artifacts.push(row?);
1833    }
1834    Ok(artifacts)
1835}
1836
1837#[cfg(feature = "turbo-quant-codec")]
1838pub(crate) fn load_derived_vector_artifacts_by_generation(
1839    conn: &Connection,
1840    generation_id: &str,
1841) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1842    let mut stmt = conn.prepare(
1843        "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1844                encoded_digest, encoding, dim, status, encoded,
1845                codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1846         FROM derived_vector_artifacts
1847         WHERE generation_id = ?1 AND status = 'active'",
1848    )?;
1849    let rows = stmt.query_map(params![generation_id], |row| {
1850        let dim_i64: i64 = row.get(7)?;
1851        Ok(DerivedVectorArtifactRow {
1852            item_key: row.get(0)?,
1853            generation_id: row.get(1)?,
1854            codec_family: row.get(2)?,
1855            codec_profile_digest: row.get(3)?,
1856            source_embedding_digest: row.get(4)?,
1857            encoded_digest: row.get(5)?,
1858            encoding: row.get(6)?,
1859            dim: usize::try_from(dim_i64).map_err(|err| {
1860                rusqlite::Error::FromSqlConversionFailure(
1861                    7,
1862                    rusqlite::types::Type::Integer,
1863                    Box::new(err),
1864                )
1865            })?,
1866            status: row.get(8)?,
1867            encoded: row.get(9)?,
1868            codec_governance_receipt_id: row.get(10)?,
1869            codec_profile: row.get(11)?,
1870            degradation_budget: row.get(12)?,
1871            raw_source_artifact_id: row.get(13)?,
1872        })
1873    })?;
1874
1875    let mut artifacts = Vec::new();
1876    for row in rows {
1877        artifacts.push(row?);
1878    }
1879    Ok(artifacts)
1880}
1881
1882#[cfg(feature = "turbo-quant-codec")]
1883pub(crate) fn current_derived_vector_generation(
1884    conn: &Connection,
1885    codec_family: &str,
1886    codec_profile_digest: &str,
1887) -> Result<Option<DerivedVectorArtifactGenerationRow>, MemoryError> {
1888    conn.query_row(
1889        "SELECT generation_id, codec_family, codec_profile_digest, source_snapshot_digest,
1890                source_row_count, artifact_count, dim, encoding, artifact_manifest_digest, status
1891         FROM derived_vector_artifact_generations
1892         WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'
1893         ORDER BY created_at DESC
1894         LIMIT 1",
1895        params![codec_family, codec_profile_digest],
1896        |row| {
1897            let source_row_count: i64 = row.get(4)?;
1898            let artifact_count: i64 = row.get(5)?;
1899            let dim: i64 = row.get(6)?;
1900            Ok(DerivedVectorArtifactGenerationRow {
1901                generation_id: row.get(0)?,
1902                codec_family: row.get(1)?,
1903                codec_profile_digest: row.get(2)?,
1904                source_snapshot_digest: row.get(3)?,
1905                source_row_count: usize::try_from(source_row_count).map_err(|err| {
1906                    rusqlite::Error::FromSqlConversionFailure(
1907                        4,
1908                        rusqlite::types::Type::Integer,
1909                        Box::new(err),
1910                    )
1911                })?,
1912                artifact_count: usize::try_from(artifact_count).map_err(|err| {
1913                    rusqlite::Error::FromSqlConversionFailure(
1914                        5,
1915                        rusqlite::types::Type::Integer,
1916                        Box::new(err),
1917                    )
1918                })?,
1919                dim: usize::try_from(dim).map_err(|err| {
1920                    rusqlite::Error::FromSqlConversionFailure(
1921                        6,
1922                        rusqlite::types::Type::Integer,
1923                        Box::new(err),
1924                    )
1925                })?,
1926                encoding: row.get(7)?,
1927                artifact_manifest_digest: row.get(8)?,
1928                status: row.get(9)?,
1929            })
1930        },
1931    )
1932    .optional()
1933    .map_err(MemoryError::from)
1934}
1935
1936#[allow(dead_code)] // public API — used by external consumers, not internally
1937pub fn count_derived_vector_artifacts(
1938    conn: &Connection,
1939    codec_family: &str,
1940    codec_profile_digest: &str,
1941) -> Result<usize, MemoryError> {
1942    let count: i64 = conn.query_row(
1943        "SELECT COUNT(*) FROM derived_vector_artifacts
1944         WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1945        params![codec_family, codec_profile_digest],
1946        |row| row.get(0),
1947    )?;
1948    usize::try_from(count)
1949        .map_err(|err| MemoryError::Other(format!("derived artifact count overflow: {err}")))
1950}
1951
1952#[cfg(feature = "turbo-quant-codec")]
1953pub(crate) fn rebuild_turbo_quant_artifacts(
1954    conn: &Connection,
1955    dim: usize,
1956    bits: u8,
1957    projections: usize,
1958    seed: u64,
1959) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1960    use crate::vector_codec::{TurboQuantCodec, VectorCodec};
1961
1962    let started = std::time::Instant::now();
1963    let codec = TurboQuantCodec::new(dim, bits, projections, seed)?;
1964    let codec_profile_digest = codec.profile().digest();
1965    let generation_id = uuid::Uuid::new_v4().to_string();
1966    let mut source_row_count = 0usize;
1967    let mut artifact_count = 0usize;
1968    let mut skipped_row_count = 0usize;
1969    let mut degradations = Vec::new();
1970
1971    let mut stmt = conn.prepare(
1972        "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1973         UNION ALL
1974         SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1975         UNION ALL
1976         SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1977         UNION ALL
1978         SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1979    )?;
1980    let rows = stmt.query_map([], |row| {
1981        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1982    })?;
1983
1984    let mut pending = Vec::new();
1985    for row in rows {
1986        let (item_key, blob) = row?;
1987        source_row_count += 1;
1988        let embedding = match decode_f32_le(&blob, dim) {
1989            Ok(embedding) => embedding,
1990            Err(err) => {
1991                skipped_row_count += 1;
1992                degradations.push(format!(
1993                    "skipped {item_key}: invalid authoritative embedding: {err}"
1994                ));
1995                continue;
1996            }
1997        };
1998        let artifact = match codec.encode(&embedding) {
1999            Ok(artifact) => artifact,
2000            Err(err) => {
2001                skipped_row_count += 1;
2002                degradations.push(format!("skipped {item_key}: encode failed: {err}"));
2003                continue;
2004            }
2005        };
2006        pending.push(DerivedVectorArtifactRow {
2007            item_key,
2008            generation_id: Some(generation_id.clone()),
2009            codec_family: "turbo_quant".to_string(),
2010            codec_profile_digest: codec_profile_digest.clone(),
2011            source_embedding_digest: source_embedding_digest(&blob, dim)?,
2012            encoded_digest: artifact.artifact_digest,
2013            encoding: "turbo_code_wire_v1".to_string(),
2014            dim,
2015            status: "active".to_string(),
2016            encoded: artifact.encoded,
2017            // V23 governance columns — populated by encode_governed path; existing
2018            // turbo-quant build path leaves these as None (nullable).
2019            codec_governance_receipt_id: None,
2020            codec_profile: None,
2021            degradation_budget: None,
2022            raw_source_artifact_id: None,
2023        });
2024    }
2025    drop(stmt);
2026
2027    let build_receipt_id = uuid::Uuid::new_v4().to_string();
2028    let source_snapshot_digest = source_snapshot_digest(&pending, dim);
2029    let artifact_manifest_digest = derived_artifact_manifest_digest(&pending);
2030    let source_tables = vec![
2031        "facts".to_string(),
2032        "chunks".to_string(),
2033        "messages".to_string(),
2034        "episodes".to_string(),
2035    ];
2036    let generation_manifest = DerivedVectorArtifactGenerationV1 {
2037        schema_version: "derived_vector_artifact_generation_v1".to_string(),
2038        generation_id: generation_id.clone(),
2039        codec_family: "turbo_quant".to_string(),
2040        codec_profile_digest: codec_profile_digest.clone(),
2041        source_snapshot_digest: source_snapshot_digest.clone(),
2042        source_row_count,
2043        artifact_count: pending.len(),
2044        source_tables,
2045        dim,
2046        encoding: "turbo_code_wire_v1".to_string(),
2047        created_at: Utc::now(),
2048        build_receipt_id: Some(build_receipt_id.clone()),
2049        artifact_manifest_digest: artifact_manifest_digest.clone(),
2050        status: if skipped_row_count == 0 {
2051            "active".to_string()
2052        } else {
2053            "failed".to_string()
2054        },
2055        degradations: degradations.clone(),
2056    };
2057
2058    with_transaction(conn, |tx| {
2059        tx.execute(
2060            "UPDATE derived_vector_artifact_generations
2061             SET status = 'superseded'
2062             WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
2063            params!["turbo_quant", &codec_profile_digest],
2064        )?;
2065        tx.execute(
2066            "DELETE FROM derived_vector_artifacts
2067             WHERE codec_family = ?1 AND codec_profile_digest = ?2",
2068            params!["turbo_quant", &codec_profile_digest],
2069        )?;
2070        tx.execute(
2071            "INSERT INTO derived_vector_artifact_generations
2072                (generation_id, schema_version, codec_family, codec_profile_digest,
2073                 source_snapshot_digest, source_row_count, artifact_count, source_tables_json,
2074                 dim, encoding, created_at, build_receipt_id, artifact_manifest_digest,
2075                 status, degradations_json)
2076             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
2077            params![
2078                generation_manifest.generation_id,
2079                generation_manifest.schema_version,
2080                generation_manifest.codec_family,
2081                generation_manifest.codec_profile_digest,
2082                generation_manifest.source_snapshot_digest,
2083                i64::try_from(generation_manifest.source_row_count).map_err(|err| {
2084                    MemoryError::Other(format!("source row count overflow: {err}"))
2085                })?,
2086                i64::try_from(generation_manifest.artifact_count).map_err(|err| {
2087                    MemoryError::Other(format!("artifact count overflow: {err}"))
2088                })?,
2089                serde_json::to_string(&generation_manifest.source_tables)
2090                    .map_err(|err| MemoryError::Other(err.to_string()))?,
2091                i64::try_from(generation_manifest.dim)
2092                    .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
2093                generation_manifest.encoding,
2094                generation_manifest.created_at.to_rfc3339(),
2095                generation_manifest.build_receipt_id,
2096                generation_manifest.artifact_manifest_digest,
2097                generation_manifest.status,
2098                serde_json::to_string(&generation_manifest.degradations)
2099                    .map_err(|err| MemoryError::Other(err.to_string()))?,
2100            ],
2101        )?;
2102        for row in &pending {
2103            upsert_derived_vector_artifact(tx, row)?;
2104            artifact_count += 1;
2105        }
2106        Ok(())
2107    })?;
2108
2109    Ok(VectorArtifactBuildReceiptV1 {
2110        schema_version: "vector_artifact_build_receipt_v1".to_string(),
2111        codec_family: "turbo_quant".to_string(),
2112        codec_profile_digest,
2113        source_row_count,
2114        artifact_count,
2115        generation_id: Some(generation_id),
2116        source_snapshot_digest: Some(source_snapshot_digest),
2117        artifact_manifest_digest: Some(artifact_manifest_digest),
2118        build_receipt_id: Some(build_receipt_id),
2119        skipped_row_count,
2120        elapsed_ms: started.elapsed().as_millis(),
2121        created_at: Utc::now(),
2122        degradations,
2123    })
2124}
2125
2126fn receipt_count_to_u64(value: usize, field: &'static str) -> Result<u64, MemoryError> {
2127    u64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2128}
2129
2130fn receipt_count_to_i64(value: u64, field: &'static str) -> Result<i64, MemoryError> {
2131    i64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2132}
2133
2134fn receipt_count_to_usize(
2135    value: u64,
2136    receipt_id: &str,
2137    field: &'static str,
2138) -> Result<usize, MemoryError> {
2139    usize::try_from(value).map_err(|err| MemoryError::CorruptData {
2140        table: "search_receipts",
2141        row_id: receipt_id.to_string(),
2142        detail: format!("{field} does not fit this platform: {err}"),
2143    })
2144}
2145
2146fn stored_search_receipt(
2147    receipt: &VectorSearchReceiptV1,
2148) -> Result<StoredVectorSearchReceiptV1, MemoryError> {
2149    Ok(StoredVectorSearchReceiptV1 {
2150        schema_version: SEARCH_RECEIPT_SCHEMA_VERSION.to_string(),
2151        receipt_id: receipt.receipt_id.clone(),
2152        evaluation_time: receipt.evaluation_time,
2153        receipt_digest: receipt.receipt_digest.clone(),
2154        trace_id: receipt.trace_id.clone(),
2155        attempt_family_id: receipt.attempt_family_id.clone(),
2156        attempt_id: receipt.attempt_id.clone(),
2157        replay_of: receipt.replay_of.clone(),
2158        query_embedding_digest: receipt.query_embedding_digest.clone(),
2159        query_text_digest: receipt.query_text_digest.clone(),
2160        query_input_digest: receipt.query_input_digest.clone(),
2161        filter_digest: receipt.filter_digest.clone(),
2162        redaction_state: receipt.redaction_state.clone(),
2163        budget_id: receipt.budget_id.clone(),
2164        deadline_at: receipt.deadline_at,
2165        search_profile: receipt.search_profile.clone(),
2166        candidate_backend: receipt.candidate_backend.clone(),
2167        codec_family: receipt.codec_family.clone(),
2168        codec_profile_digest: receipt.codec_profile_digest.clone(),
2169        artifact_profile_digest: receipt.artifact_profile_digest.clone(),
2170        artifact_count: receipt
2171            .artifact_count
2172            .map(|value| receipt_count_to_u64(value, "artifact_count"))
2173            .transpose()?,
2174        artifact_corruption_count: receipt
2175            .artifact_corruption_count
2176            .map(|value| receipt_count_to_u64(value, "artifact_corruption_count"))
2177            .transpose()?,
2178        artifact_missing_count: receipt
2179            .artifact_missing_count
2180            .map(|value| receipt_count_to_u64(value, "artifact_missing_count"))
2181            .transpose()?,
2182        vector_artifact_manifest_digest: receipt.vector_artifact_manifest_digest.clone(),
2183        artifact_generation_id: receipt.artifact_generation_id.clone(),
2184        approximate_scanned_count: receipt
2185            .approximate_scanned_count
2186            .map(|value| receipt_count_to_u64(value, "approximate_scanned_count"))
2187            .transpose()?,
2188        approximate_returned_count: receipt
2189            .approximate_returned_count
2190            .map(|value| receipt_count_to_u64(value, "approximate_returned_count"))
2191            .transpose()?,
2192        raw_rows_loaded_count: receipt
2193            .raw_rows_loaded_count
2194            .map(|value| receipt_count_to_u64(value, "raw_rows_loaded_count"))
2195            .transpose()?,
2196        filter_strategy: receipt.filter_strategy.clone(),
2197        vector_artifact_count: receipt
2198            .vector_artifact_count
2199            .map(|value| receipt_count_to_u64(value, "vector_artifact_count"))
2200            .transpose()?,
2201        vector_artifact_missing_count: receipt
2202            .vector_artifact_missing_count
2203            .map(|value| receipt_count_to_u64(value, "vector_artifact_missing_count"))
2204            .transpose()?,
2205        vector_artifact_stale_count: receipt
2206            .vector_artifact_stale_count
2207            .map(|value| receipt_count_to_u64(value, "vector_artifact_stale_count"))
2208            .transpose()?,
2209        exact_rerank_count: receipt
2210            .exact_rerank_count
2211            .map(|value| receipt_count_to_u64(value, "exact_rerank_count"))
2212            .transpose()?,
2213        approximate_candidate_count: receipt
2214            .approximate_candidate_count
2215            .map(|value| receipt_count_to_u64(value, "approximate_candidate_count"))
2216            .transpose()?,
2217        fallback_reason: receipt.fallback_reason.clone(),
2218        approximate: receipt.approximate,
2219        requested_candidates: receipt_count_to_u64(
2220            receipt.requested_candidates,
2221            "requested_candidates",
2222        )?,
2223        returned_candidates: receipt_count_to_u64(
2224            receipt.returned_candidates,
2225            "returned_candidates",
2226        )?,
2227        post_filter_candidates: receipt_count_to_u64(
2228            receipt.post_filter_candidates,
2229            "post_filter_candidates",
2230        )?,
2231        sparse_enabled: receipt.sparse_enabled,
2232        sparse_weight: receipt.sparse_weight,
2233        sparse_query_nonzero_count: receipt
2234            .sparse_query_nonzero_count
2235            .map(|value| receipt_count_to_u64(value, "sparse_query_nonzero_count"))
2236            .transpose()?,
2237        sparse_candidate_count: receipt
2238            .sparse_candidate_count
2239            .map(|value| receipt_count_to_u64(value, "sparse_candidate_count"))
2240            .transpose()?,
2241        sparse_representations: receipt.sparse_representations.clone(),
2242        sparse_result_ranks: receipt.sparse_result_ranks.clone(),
2243        fallback: receipt.fallback.clone(),
2244        exact_rerank: receipt.exact_rerank,
2245        result_ids: receipt.result_ids.clone(),
2246        degradations: receipt.degradations.clone(),
2247    })
2248}
2249
2250fn search_receipt_from_stored(
2251    stored: StoredVectorSearchReceiptV1,
2252) -> Result<VectorSearchReceiptV1, MemoryError> {
2253    if stored.schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2254        return Err(MemoryError::CorruptData {
2255            table: "search_receipts",
2256            row_id: stored.receipt_id,
2257            detail: format!(
2258                "unsupported receipt schema version '{}'",
2259                stored.schema_version
2260            ),
2261        });
2262    }
2263
2264    Ok(VectorSearchReceiptV1 {
2265        schema_version: stored.schema_version.clone(),
2266        receipt_digest: stored.receipt_digest,
2267        receipt_id: stored.receipt_id.clone(),
2268        evaluation_time: stored.evaluation_time,
2269        trace_id: stored.trace_id,
2270        attempt_family_id: stored.attempt_family_id,
2271        attempt_id: stored.attempt_id,
2272        replay_of: stored.replay_of,
2273        query_embedding_digest: stored.query_embedding_digest,
2274        query_text_digest: stored.query_text_digest,
2275        query_input_digest: stored.query_input_digest,
2276        filter_digest: stored.filter_digest,
2277        redaction_state: stored.redaction_state,
2278        budget_id: stored.budget_id,
2279        deadline_at: stored.deadline_at,
2280        search_profile: stored.search_profile,
2281        candidate_backend: stored.candidate_backend,
2282        codec_family: stored.codec_family,
2283        codec_profile_digest: stored.codec_profile_digest,
2284        artifact_profile_digest: stored.artifact_profile_digest,
2285        artifact_count: stored
2286            .artifact_count
2287            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "artifact_count"))
2288            .transpose()?,
2289        artifact_corruption_count: stored
2290            .artifact_corruption_count
2291            .map(|value| {
2292                receipt_count_to_usize(value, &stored.receipt_id, "artifact_corruption_count")
2293            })
2294            .transpose()?,
2295        artifact_missing_count: stored
2296            .artifact_missing_count
2297            .map(|value| {
2298                receipt_count_to_usize(value, &stored.receipt_id, "artifact_missing_count")
2299            })
2300            .transpose()?,
2301        vector_artifact_manifest_digest: stored.vector_artifact_manifest_digest,
2302        artifact_generation_id: stored.artifact_generation_id,
2303        approximate_scanned_count: stored
2304            .approximate_scanned_count
2305            .map(|value| {
2306                receipt_count_to_usize(value, &stored.receipt_id, "approximate_scanned_count")
2307            })
2308            .transpose()?,
2309        approximate_returned_count: stored
2310            .approximate_returned_count
2311            .map(|value| {
2312                receipt_count_to_usize(value, &stored.receipt_id, "approximate_returned_count")
2313            })
2314            .transpose()?,
2315        raw_rows_loaded_count: stored
2316            .raw_rows_loaded_count
2317            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "raw_rows_loaded_count"))
2318            .transpose()?,
2319        filter_strategy: stored.filter_strategy,
2320        vector_artifact_count: stored
2321            .vector_artifact_count
2322            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_count"))
2323            .transpose()?,
2324        vector_artifact_missing_count: stored
2325            .vector_artifact_missing_count
2326            .map(|value| {
2327                receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_missing_count")
2328            })
2329            .transpose()?,
2330        vector_artifact_stale_count: stored
2331            .vector_artifact_stale_count
2332            .map(|value| {
2333                receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_stale_count")
2334            })
2335            .transpose()?,
2336        exact_rerank_count: stored
2337            .exact_rerank_count
2338            .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "exact_rerank_count"))
2339            .transpose()?,
2340        approximate_candidate_count: stored
2341            .approximate_candidate_count
2342            .map(|value| {
2343                receipt_count_to_usize(value, &stored.receipt_id, "approximate_candidate_count")
2344            })
2345            .transpose()?,
2346        fallback_reason: stored.fallback_reason,
2347        derived_candidate: None,
2348        approximate: stored.approximate,
2349        requested_candidates: receipt_count_to_usize(
2350            stored.requested_candidates,
2351            &stored.receipt_id,
2352            "requested_candidates",
2353        )?,
2354        returned_candidates: receipt_count_to_usize(
2355            stored.returned_candidates,
2356            &stored.receipt_id,
2357            "returned_candidates",
2358        )?,
2359        post_filter_candidates: receipt_count_to_usize(
2360            stored.post_filter_candidates,
2361            &stored.receipt_id,
2362            "post_filter_candidates",
2363        )?,
2364        sparse_enabled: stored.sparse_enabled,
2365        sparse_weight: stored.sparse_weight,
2366        sparse_query_nonzero_count: stored
2367            .sparse_query_nonzero_count
2368            .map(|value| {
2369                receipt_count_to_usize(value, &stored.receipt_id, "sparse_query_nonzero_count")
2370            })
2371            .transpose()?,
2372        sparse_candidate_count: stored
2373            .sparse_candidate_count
2374            .map(|value| {
2375                receipt_count_to_usize(value, &stored.receipt_id, "sparse_candidate_count")
2376            })
2377            .transpose()?,
2378        sparse_representations: stored.sparse_representations,
2379        sparse_result_ranks: stored.sparse_result_ranks,
2380        fallback: stored.fallback,
2381        exact_rerank: stored.exact_rerank,
2382        result_ids: stored.result_ids,
2383        degradations: stored.degradations,
2384    })
2385}
2386
2387/// Persist a search receipt as replay metadata.
2388///
2389/// SQLite rows remain authoritative for memory. This table stores only the
2390/// execution receipt and digest so the search can be addressed later.
2391pub fn store_search_receipt(
2392    conn: &Connection,
2393    receipt: &VectorSearchReceiptV1,
2394) -> Result<(), MemoryError> {
2395    let stored = stored_search_receipt(receipt)?;
2396    let receipt_json = serde_json::to_string(&stored)
2397        .map_err(|err| MemoryError::Other(format!("failed to serialize search receipt: {err}")))?;
2398    let receipt_digest = b3_digest(receipt_json.as_bytes());
2399
2400    let existing_digest: Option<String> = conn
2401        .query_row(
2402            "SELECT receipt_digest FROM search_receipts WHERE receipt_id = ?1",
2403            params![&stored.receipt_id],
2404            |row| row.get(0),
2405        )
2406        .optional()?;
2407    if let Some(existing_digest) = existing_digest {
2408        if existing_digest == receipt_digest {
2409            return Ok(());
2410        }
2411        return Err(MemoryError::SearchReceiptConflict {
2412            receipt_id: stored.receipt_id,
2413        });
2414    }
2415
2416    let result_ids_json = serde_json::to_string(&stored.result_ids).map_err(|err| {
2417        MemoryError::Other(format!(
2418            "failed to serialize search receipt result IDs: {err}"
2419        ))
2420    })?;
2421    conn.execute(
2422        "INSERT INTO search_receipts (
2423            receipt_id,
2424            schema_version,
2425            evaluation_time,
2426            search_profile,
2427            candidate_backend,
2428            approximate,
2429            exact_rerank,
2430            fallback,
2431            requested_candidates,
2432            returned_candidates,
2433            post_filter_candidates,
2434            result_ids_json,
2435            receipt_json,
2436            receipt_digest
2437        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
2438        params![
2439            &stored.receipt_id,
2440            SEARCH_RECEIPT_SCHEMA_VERSION,
2441            stored.evaluation_time.to_rfc3339(),
2442            &stored.search_profile,
2443            &stored.candidate_backend,
2444            if stored.approximate { 1_i64 } else { 0_i64 },
2445            if stored.exact_rerank { 1_i64 } else { 0_i64 },
2446            &stored.fallback,
2447            receipt_count_to_i64(stored.requested_candidates, "requested_candidates")?,
2448            receipt_count_to_i64(stored.returned_candidates, "returned_candidates")?,
2449            receipt_count_to_i64(stored.post_filter_candidates, "post_filter_candidates")?,
2450            &result_ids_json,
2451            &receipt_json,
2452            &receipt_digest,
2453        ],
2454    )?;
2455    Ok(())
2456}
2457
2458/// Load a durable search receipt by receipt/request ID.
2459pub fn get_search_receipt(
2460    conn: &Connection,
2461    receipt_id: &str,
2462) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
2463    let row: Option<(String, String, String)> = conn
2464        .query_row(
2465            "SELECT schema_version, receipt_json, receipt_digest
2466             FROM search_receipts
2467             WHERE receipt_id = ?1",
2468            params![receipt_id],
2469            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2470        )
2471        .optional()?;
2472
2473    let Some((schema_version, receipt_json, receipt_digest)) = row else {
2474        return Ok(None);
2475    };
2476    if schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2477        return Err(MemoryError::CorruptData {
2478            table: "search_receipts",
2479            row_id: receipt_id.to_string(),
2480            detail: format!("unsupported receipt schema version '{schema_version}'"),
2481        });
2482    }
2483
2484    let stored: StoredVectorSearchReceiptV1 =
2485        serde_json::from_str(&receipt_json).map_err(|err| MemoryError::CorruptData {
2486            table: "search_receipts",
2487            row_id: receipt_id.to_string(),
2488            detail: format!("invalid receipt JSON: {err}"),
2489        })?;
2490    let mut receipt = search_receipt_from_stored(stored)?;
2491    receipt.receipt_digest = Some(receipt_digest);
2492    Ok(Some(receipt))
2493}
2494
2495/// Privacy-sensitive inputs retained by explicit replay opt-in.
2496pub(crate) struct ReplayInputs {
2497    pub query_text: String,
2498    pub namespaces: Option<Vec<String>>,
2499    pub source_types: Option<Vec<SearchSourceType>>,
2500}
2501
2502/// Persist the inputs needed to replay a durable search receipt.
2503pub(crate) fn store_replay_inputs(
2504    conn: &Connection,
2505    receipt_id: &str,
2506    query_text: &str,
2507    namespaces: Option<&[&str]>,
2508    source_types: Option<&[SearchSourceType]>,
2509) -> Result<(), MemoryError> {
2510    let namespaces_json = namespaces
2511        .map(serde_json::to_string)
2512        .transpose()
2513        .map_err(|error| {
2514            MemoryError::Other(format!("failed to serialize replay namespaces: {error}"))
2515        })?;
2516    let source_types_json = source_types
2517        .map(serde_json::to_string)
2518        .transpose()
2519        .map_err(|error| {
2520            MemoryError::Other(format!("failed to serialize replay source types: {error}"))
2521        })?;
2522
2523    let existing: Option<(String, Option<String>, Option<String>)> = conn
2524        .query_row(
2525            "SELECT query_text, namespaces_json, source_types_json
2526             FROM replay_inputs WHERE receipt_id = ?1",
2527            params![receipt_id],
2528            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2529        )
2530        .optional()?;
2531    if let Some(existing) = existing {
2532        if existing.0 == query_text
2533            && existing.1.as_ref() == namespaces_json.as_ref()
2534            && existing.2.as_ref() == source_types_json.as_ref()
2535        {
2536            return Ok(());
2537        }
2538        return Err(MemoryError::SearchReceiptConflict {
2539            receipt_id: receipt_id.to_string(),
2540        });
2541    }
2542
2543    conn.execute(
2544        "INSERT INTO replay_inputs
2545            (receipt_id, query_text, namespaces_json, source_types_json)
2546         VALUES (?1, ?2, ?3, ?4)",
2547        params![receipt_id, query_text, namespaces_json, source_types_json],
2548    )?;
2549    Ok(())
2550}
2551
2552/// Load opt-in inputs for complete replay, if present.
2553pub(crate) fn get_replay_inputs(
2554    conn: &Connection,
2555    receipt_id: &str,
2556) -> Result<Option<ReplayInputs>, MemoryError> {
2557    let row: Option<(String, Option<String>, Option<String>)> = conn
2558        .query_row(
2559            "SELECT query_text, namespaces_json, source_types_json
2560             FROM replay_inputs WHERE receipt_id = ?1",
2561            params![receipt_id],
2562            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2563        )
2564        .optional()?;
2565    let Some((query_text, namespaces_json, source_types_json)) = row else {
2566        return Ok(None);
2567    };
2568    let namespaces = namespaces_json
2569        .map(|json| serde_json::from_str(&json))
2570        .transpose()
2571        .map_err(|error| MemoryError::CorruptData {
2572            table: "replay_inputs",
2573            row_id: receipt_id.to_string(),
2574            detail: format!("invalid namespaces JSON: {error}"),
2575        })?;
2576    let source_types = source_types_json
2577        .map(|json| serde_json::from_str(&json))
2578        .transpose()
2579        .map_err(|error| MemoryError::CorruptData {
2580            table: "replay_inputs",
2581            row_id: receipt_id.to_string(),
2582            detail: format!("invalid source types JSON: {error}"),
2583        })?;
2584    Ok(Some(ReplayInputs {
2585        query_text,
2586        namespaces,
2587        source_types,
2588    }))
2589}
2590
2591fn add_column_if_missing(
2592    conn: &Connection,
2593    table: &str,
2594    column: &str,
2595    column_sql: &str,
2596) -> Result<(), rusqlite::Error> {
2597    let pragma = format!("PRAGMA table_info({table})");
2598    let mut stmt = conn.prepare(&pragma)?;
2599    let exists = stmt
2600        .query_map([], |row| row.get::<_, String>(1))?
2601        .collect::<Result<Vec<_>, _>>()?
2602        .into_iter()
2603        .any(|name| name == column);
2604
2605    if !exists {
2606        conn.execute(
2607            &format!("ALTER TABLE {table} ADD COLUMN {column} {column_sql}"),
2608            [],
2609        )?;
2610    }
2611
2612    Ok(())
2613}
2614
2615/// Check and update the embedding metadata singleton row.
2616pub fn check_embedding_metadata(
2617    conn: &Connection,
2618    config: &EmbeddingConfig,
2619) -> Result<(), MemoryError> {
2620    // INTENTIONAL: row absent on first run before metadata is inserted
2621    let existing: Option<(String, usize)> = conn
2622        .query_row(
2623            "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2624            [],
2625            |row| Ok((row.get(0)?, row.get(1)?)),
2626        )
2627        .ok();
2628
2629    match existing {
2630        Some((model, dims)) => {
2631            if model != config.model || dims != config.dimensions {
2632                tracing::warn!(
2633                    stored_model = %model,
2634                    stored_dims = dims,
2635                    configured_model = %config.model,
2636                    configured_dims = config.dimensions,
2637                    "Embedding model changed. Existing embeddings are stale."
2638                );
2639                conn.execute(
2640                    "UPDATE embedding_metadata
2641                     SET model_name = ?1,
2642                         dimensions = ?2,
2643                         embeddings_dirty = 1,
2644                         updated_at = datetime('now')
2645                     WHERE id = 1",
2646                    params![config.model, config.dimensions],
2647                )?;
2648            }
2649        }
2650        None => {
2651            conn.execute(
2652                "INSERT INTO embedding_metadata (id, model_name, dimensions) VALUES (1, ?1, ?2)",
2653                params![config.model, config.dimensions],
2654            )?;
2655        }
2656    }
2657
2658    Ok(())
2659}
2660
2661/// Encode an f32 slice as bytes for SQLite BLOB storage.
2662pub fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
2663    encode_f32_le(embedding)
2664}
2665
2666/// One durable sparse-vector row used by the sparse retrieval lane.
2667#[derive(Debug, Clone)]
2668pub(crate) struct SparseVectorRow {
2669    pub item_key: String,
2670    pub weights: SparseWeights,
2671    pub representation: String,
2672}
2673
2674/// Insert or replace the sparse representation for a canonical search item.
2675pub(crate) fn store_sparse_vector(
2676    conn: &Connection,
2677    item_key: &str,
2678    weights: &SparseWeights,
2679    representation: &str,
2680) -> Result<(), MemoryError> {
2681    if item_key.split_once(':').is_none() || representation.trim().is_empty() {
2682        return Err(MemoryError::InvalidConfig {
2683            field: "sparse_vector",
2684            reason: "item_key must be canonical and representation must not be empty".to_string(),
2685        });
2686    }
2687    if weights
2688        .entries
2689        .iter()
2690        .any(|(_, weight)| !weight.is_finite())
2691    {
2692        return Err(MemoryError::InvalidConfig {
2693            field: "sparse_vector.entries",
2694            reason: "sparse weights must be finite".to_string(),
2695        });
2696    }
2697    let entries_json = serde_json::to_string(&weights.entries)
2698        .map_err(|error| MemoryError::Other(format!("serialize sparse weights: {error}")))?;
2699    conn.execute(
2700        "INSERT INTO sparse_vectors (item_key, entries_json, representation, updated_at)
2701         VALUES (?1, ?2, ?3, datetime('now'))
2702         ON CONFLICT(item_key) DO UPDATE SET
2703             entries_json = excluded.entries_json,
2704             representation = excluded.representation,
2705             updated_at = excluded.updated_at",
2706        params![item_key, entries_json, representation],
2707    )?;
2708    Ok(())
2709}
2710
2711/// Load a bounded set of durable sparse rows selected by sparse dot product.
2712pub(crate) fn search_sparse_vectors(
2713    conn: &Connection,
2714    query: &SparseWeights,
2715    candidate_limit: usize,
2716    min_score: f64,
2717) -> Result<Vec<(SparseVectorRow, f64)>, MemoryError> {
2718    if query.is_empty() || candidate_limit == 0 {
2719        return Ok(Vec::new());
2720    }
2721
2722    let mut sql = String::from("WITH query_sparse(dimension, weight) AS (VALUES ");
2723    for index in 0..query.entries.len() {
2724        if index > 0 {
2725            sql.push(',');
2726        }
2727        let dimension_param = index * 2 + 1;
2728        let weight_param = dimension_param + 1;
2729        sql.push_str(&format!("(?{dimension_param}, ?{weight_param})"));
2730    }
2731    let min_score_param = query.entries.len() * 2 + 1;
2732    let limit_param = min_score_param + 1;
2733    sql.push_str(&format!(
2734        ") SELECT sv.item_key, sv.entries_json, sv.representation,
2735                  SUM(CAST(json_extract(entry.value, '$[1]') AS REAL) * query_sparse.weight) AS sparse_score
2736           FROM sparse_vectors sv
2737           JOIN json_each(sv.entries_json) AS entry
2738           JOIN query_sparse
2739             ON CAST(json_extract(entry.value, '$[0]') AS INTEGER) = query_sparse.dimension
2740           GROUP BY sv.item_key
2741           HAVING sparse_score >= ?{min_score_param}
2742           ORDER BY sparse_score DESC, sv.item_key ASC
2743           LIMIT ?{limit_param}"
2744    ));
2745
2746    let mut values: Vec<rusqlite::types::Value> = Vec::with_capacity(query.entries.len() * 2 + 2);
2747    for (dimension, weight) in &query.entries {
2748        values.push((*dimension as i64).into());
2749        values.push(f64::from(*weight).into());
2750    }
2751    values.push(min_score.into());
2752    values.push((candidate_limit as i64).into());
2753
2754    let mut statement = conn.prepare(&sql)?;
2755    let rows = statement.query_map(rusqlite::params_from_iter(values), |row| {
2756        Ok((
2757            row.get::<_, String>(0)?,
2758            row.get::<_, String>(1)?,
2759            row.get::<_, String>(2)?,
2760            row.get::<_, f64>(3)?,
2761        ))
2762    })?;
2763    let mut results = Vec::new();
2764    for row in rows {
2765        let (item_key, entries_json, representation, score) = row?;
2766        let entries: Vec<(usize, f32)> =
2767            serde_json::from_str(&entries_json).map_err(|error| MemoryError::CorruptData {
2768                table: "sparse_vectors",
2769                row_id: item_key.clone(),
2770                detail: format!("invalid sparse entries JSON: {error}"),
2771            })?;
2772        results.push((
2773            SparseVectorRow {
2774                item_key,
2775                weights: SparseWeights::from_entries(entries),
2776                representation,
2777            },
2778            score,
2779        ));
2780    }
2781    Ok(results)
2782}
2783
2784/// Remove a sparse vector explicitly (used for supersession cleanup).
2785pub(crate) fn delete_sparse_vector(conn: &Connection, item_key: &str) -> Result<(), MemoryError> {
2786    conn.execute(
2787        "DELETE FROM sparse_vectors WHERE item_key = ?1",
2788        params![item_key],
2789    )?;
2790    Ok(())
2791}
2792
2793/// Encode f32 values as a stable little-endian persisted representation.
2794pub fn encode_f32_le(values: &[f32]) -> Vec<u8> {
2795    let mut bytes = Vec::with_capacity(values.len() * 4);
2796    for value in values {
2797        bytes.extend_from_slice(&value.to_le_bytes());
2798    }
2799    bytes
2800}
2801
2802/// Validate an embedding vector before it is stored or indexed.
2803pub(crate) fn validate_embedding(values: &[f32], expected_dim: usize) -> Result<(), MemoryError> {
2804    if values.len() != expected_dim {
2805        return Err(MemoryError::EmbeddingDimensionMismatch {
2806            expected: expected_dim,
2807            actual: values.len(),
2808        });
2809    }
2810    if let Some((index, _)) = values
2811        .iter()
2812        .enumerate()
2813        .find(|(_, value)| !value.is_finite())
2814    {
2815        return Err(MemoryError::NonFiniteEmbeddingValue { index });
2816    }
2817    Ok(())
2818}
2819
2820/// Validate a returned embedding batch against the requested input count.
2821pub(crate) fn validate_embedding_batch(
2822    values: &[Vec<f32>],
2823    requested: usize,
2824    expected_dim: usize,
2825) -> Result<(), MemoryError> {
2826    if values.len() != requested {
2827        return Err(MemoryError::EmbeddingBatchCountMismatch {
2828            requested,
2829            returned: values.len(),
2830        });
2831    }
2832    for embedding in values {
2833        validate_embedding(embedding, expected_dim)?;
2834    }
2835    Ok(())
2836}
2837
2838/// Validate the exact byte length of a persisted f32 vector blob.
2839pub(crate) fn validate_vector_blob_len(
2840    bytes: &[u8],
2841    expected_dim: usize,
2842) -> Result<(), MemoryError> {
2843    let expected_bytes = expected_dim
2844        .checked_mul(4)
2845        .ok_or_else(|| MemoryError::InvalidConfig {
2846            field: "embedding.dimensions",
2847            reason: "dimension byte length overflow".to_string(),
2848        })?;
2849    if bytes.len() != expected_bytes {
2850        return Err(MemoryError::VectorBlobLengthMismatch {
2851            expected_bytes,
2852            actual_bytes: bytes.len(),
2853        });
2854    }
2855    Ok(())
2856}
2857
2858/// Decode a stable little-endian f32 persisted representation.
2859#[allow(clippy::manual_is_multiple_of)]
2860pub fn decode_f32_le(bytes: &[u8], expected_dim: usize) -> Result<Vec<f32>, MemoryError> {
2861    validate_vector_blob_len(bytes, expected_dim)?;
2862    decode_f32_le_unchecked_dim(bytes)
2863}
2864
2865/// Decode a SQLite embedding BLOB back to f32 values.
2866#[allow(clippy::manual_is_multiple_of)]
2867pub fn bytes_to_embedding(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2868    if bytes.len() % 4 != 0 {
2869        return Err(MemoryError::InvalidEmbedding {
2870            expected_bytes: bytes.len() - (bytes.len() % 4),
2871            actual_bytes: bytes.len(),
2872        });
2873    }
2874
2875    decode_f32_le_unchecked_dim(bytes)
2876}
2877
2878fn decode_f32_le_unchecked_dim(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2879    let mut embedding = Vec::with_capacity(bytes.len() / 4);
2880    for (index, chunk) in bytes.chunks_exact(4).enumerate() {
2881        let value = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
2882        if !value.is_finite() {
2883            return Err(MemoryError::NonFiniteEmbeddingValue { index });
2884        }
2885        embedding.push(value);
2886    }
2887    Ok(embedding)
2888}
2889
2890pub fn is_embeddings_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2891    let dirty: i32 = conn
2892        .query_row(
2893            "SELECT COALESCE(embeddings_dirty, 0) FROM embedding_metadata WHERE id = 1",
2894            [],
2895            |row| row.get(0),
2896        )
2897        .unwrap_or(0);
2898    Ok(dirty != 0)
2899}
2900
2901pub fn clear_embeddings_dirty(conn: &Connection) -> Result<(), MemoryError> {
2902    conn.execute(
2903        "UPDATE embedding_metadata SET embeddings_dirty = 0 WHERE id = 1",
2904        [],
2905    )?;
2906    Ok(())
2907}
2908
2909#[cfg(feature = "hnsw")]
2910pub(crate) fn queue_pending_index_op(
2911    tx: &rusqlite::Transaction<'_>,
2912    item_key: &str,
2913    entity_type: &str,
2914    op_kind: IndexOpKind,
2915) -> Result<(), MemoryError> {
2916    tx.execute(
2917        "INSERT INTO pending_index_ops (item_key, entity_type, op_kind, attempt_count, last_error, updated_at)
2918         VALUES (?1, ?2, ?3, 0, NULL, datetime('now'))
2919         ON CONFLICT(item_key) DO UPDATE SET
2920             entity_type = excluded.entity_type,
2921             op_kind = excluded.op_kind,
2922             attempt_count = 0,
2923             last_error = NULL,
2924             updated_at = datetime('now')",
2925        params![item_key, entity_type, op_kind.as_str()],
2926    )?;
2927    mark_sidecar_dirty(tx)?;
2928    Ok(())
2929}
2930
2931#[cfg(feature = "hnsw")]
2932pub(crate) use IndexOpKind as PendingIndexOpKind;
2933
2934#[cfg(feature = "hnsw")]
2935pub(crate) fn enqueue_pending_index_op(
2936    tx: &rusqlite::Transaction<'_>,
2937    item_key: &str,
2938    entity_type: &str,
2939    op_kind: PendingIndexOpKind,
2940) -> Result<(), MemoryError> {
2941    queue_pending_index_op(tx, item_key, entity_type, op_kind)
2942}
2943
2944pub(crate) fn list_pending_index_ops(
2945    conn: &Connection,
2946) -> Result<Vec<PendingIndexOp>, MemoryError> {
2947    let table_exists: bool = conn
2948        .query_row(
2949            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2950            [],
2951            |row| row.get(0),
2952        )
2953        .unwrap_or(false);
2954    if !table_exists {
2955        return Ok(Vec::new());
2956    }
2957
2958    let mut stmt = conn.prepare(
2959        "SELECT item_key, entity_type, op_kind, attempt_count, last_error
2960         FROM pending_index_ops
2961         ORDER BY updated_at ASC, item_key ASC",
2962    )?;
2963    let rows = stmt
2964        .query_map([], |row| {
2965            let item_key: String = row.get(0)?;
2966            let op_kind: String = row.get(2)?;
2967            Ok(PendingIndexOp {
2968                item_key: item_key.clone(),
2969                entity_type: row.get(1)?,
2970                op_kind: IndexOpKind::parse(&op_kind, &item_key).map_err(|e| {
2971                    rusqlite::Error::FromSqlConversionFailure(
2972                        2,
2973                        rusqlite::types::Type::Text,
2974                        Box::new(e),
2975                    )
2976                })?,
2977                attempt_count: row.get::<_, i64>(3)? as u32,
2978                last_error: row.get(4)?,
2979            })
2980        })?
2981        .collect::<Result<Vec<_>, _>>()?;
2982    Ok(rows)
2983}
2984
2985#[cfg(feature = "hnsw")]
2986pub(crate) fn pending_index_op_count(conn: &Connection) -> Result<usize, MemoryError> {
2987    let table_exists: bool = conn
2988        .query_row(
2989            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2990            [],
2991            |row| row.get(0),
2992        )
2993        .unwrap_or(false);
2994    if !table_exists {
2995        return Ok(0);
2996    }
2997
2998    let count: i64 = conn.query_row("SELECT COUNT(*) FROM pending_index_ops", [], |row| {
2999        row.get(0)
3000    })?;
3001    Ok(count as usize)
3002}
3003
3004#[cfg(feature = "hnsw")]
3005pub(crate) fn mark_pending_index_ops_failed(
3006    conn: &Connection,
3007    item_keys: &[String],
3008    error: &str,
3009) -> Result<(), MemoryError> {
3010    with_transaction(conn, |tx| {
3011        for item_key in item_keys {
3012            tx.execute(
3013                "UPDATE pending_index_ops
3014                 SET attempt_count = attempt_count + 1,
3015                     last_error = ?1,
3016                     updated_at = datetime('now')
3017                 WHERE item_key = ?2",
3018                params![error, item_key],
3019            )?;
3020        }
3021        Ok(())
3022    })
3023}
3024
3025#[cfg(feature = "hnsw")]
3026pub(crate) fn clear_pending_index_ops(
3027    conn: &Connection,
3028    item_keys: &[String],
3029) -> Result<(), MemoryError> {
3030    with_transaction(conn, |tx| {
3031        for item_key in item_keys {
3032            tx.execute(
3033                "DELETE FROM pending_index_ops WHERE item_key = ?1",
3034                params![item_key],
3035            )?;
3036        }
3037        Ok(())
3038    })
3039}
3040
3041#[cfg(feature = "hnsw")]
3042pub(crate) fn clear_all_pending_index_ops(conn: &Connection) -> Result<(), MemoryError> {
3043    conn.execute("DELETE FROM pending_index_ops", [])?;
3044    Ok(())
3045}
3046
3047#[cfg(feature = "hnsw")]
3048pub(crate) fn load_embedding_for_index_key(
3049    conn: &Connection,
3050    item_key: &str,
3051) -> Result<Option<Vec<f32>>, MemoryError> {
3052    let Some((domain, raw_id)) = item_key.split_once(':') else {
3053        return Err(MemoryError::InvalidKey(item_key.to_string()));
3054    };
3055
3056    let blob_result: Result<Option<Vec<u8>>, rusqlite::Error> = match domain {
3057        "fact" => conn.query_row(
3058            "SELECT embedding FROM facts WHERE id = ?1",
3059            params![raw_id],
3060            |row| row.get(0),
3061        ),
3062        "chunk" => conn.query_row(
3063            "SELECT embedding FROM chunks WHERE id = ?1",
3064            params![raw_id],
3065            |row| row.get(0),
3066        ),
3067        "msg" => {
3068            let message_id = raw_id
3069                .parse::<i64>()
3070                .map_err(|e| MemoryError::InvalidKey(format!("{}: {e}", item_key)))?;
3071            conn.query_row(
3072                "SELECT embedding FROM messages WHERE id = ?1",
3073                params![message_id],
3074                |row| row.get(0),
3075            )
3076        }
3077        "episode" => conn.query_row(
3078            "SELECT embedding FROM episodes WHERE episode_id = ?1",
3079            params![raw_id],
3080            |row| row.get(0),
3081        ),
3082        _ => return Err(MemoryError::InvalidKey(item_key.to_string())),
3083    };
3084
3085    let blob = match blob_result {
3086        Ok(blob) => blob,
3087        Err(rusqlite::Error::QueryReturnedNoRows) => None,
3088        Err(err) => return Err(err.into()),
3089    };
3090
3091    blob.map(|bytes| bytes_to_embedding(&bytes)).transpose()
3092}
3093
3094#[cfg(feature = "hnsw")]
3095fn mark_sidecar_dirty(tx: &rusqlite::Transaction<'_>) -> Result<(), MemoryError> {
3096    tx.execute(
3097        "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '1')
3098         ON CONFLICT(key) DO UPDATE SET value = '1'",
3099        [],
3100    )?;
3101    Ok(())
3102}
3103
3104#[cfg(feature = "hnsw")]
3105pub(crate) fn is_sidecar_dirty(conn: &Connection) -> Result<bool, MemoryError> {
3106    // INTENTIONAL: row absent when HNSW metadata has not been written yet
3107    let dirty: Option<String> = conn
3108        .query_row(
3109            "SELECT value FROM hnsw_metadata WHERE key = 'sidecar_dirty'",
3110            [],
3111            |row| row.get(0),
3112        )
3113        .ok();
3114    Ok(matches!(dirty.as_deref(), Some("1")))
3115}
3116
3117#[cfg(feature = "hnsw")]
3118pub(crate) fn set_sidecar_dirty(conn: &Connection, dirty: bool) -> Result<(), MemoryError> {
3119    conn.execute(
3120        "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', ?1)
3121         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3122        params![if dirty { "1" } else { "0" }],
3123    )?;
3124    Ok(())
3125}
3126
3127pub(crate) fn parse_optional_json(
3128    table: &'static str,
3129    row_id: &str,
3130    field: &'static str,
3131    raw: Option<&str>,
3132) -> Result<Option<serde_json::Value>, MemoryError> {
3133    match raw {
3134        Some(raw) => serde_json::from_str(raw)
3135            .map(Some)
3136            .map_err(|e| MemoryError::CorruptData {
3137                table,
3138                row_id: row_id.to_string(),
3139                detail: format!("invalid {field}: {e}"),
3140            }),
3141        None => Ok(None),
3142    }
3143}
3144
3145pub(crate) fn parse_string_list_json(
3146    table: &'static str,
3147    row_id: &str,
3148    field: &'static str,
3149    raw: &str,
3150) -> Result<Vec<String>, MemoryError> {
3151    serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3152        table,
3153        row_id: row_id.to_string(),
3154        detail: format!("invalid {field}: {e}"),
3155    })
3156}
3157
3158pub(crate) fn parse_role(
3159    table: &'static str,
3160    row_id: &str,
3161    raw: &str,
3162) -> Result<Role, MemoryError> {
3163    Role::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3164        table,
3165        row_id: row_id.to_string(),
3166        detail: format!("invalid role '{raw}'"),
3167    })
3168}
3169
3170pub(crate) fn parse_episode_outcome(
3171    row_id: &str,
3172    raw: &str,
3173) -> Result<EpisodeOutcome, MemoryError> {
3174    EpisodeOutcome::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3175        table: "episodes",
3176        row_id: row_id.to_string(),
3177        detail: format!("invalid outcome '{raw}'"),
3178    })
3179}
3180
3181pub(crate) fn parse_verification_status(
3182    row_id: &str,
3183    raw: &str,
3184) -> Result<VerificationStatus, MemoryError> {
3185    serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3186        table: "episodes",
3187        row_id: row_id.to_string(),
3188        detail: format!("invalid verification_status: {e}"),
3189    })
3190}
3191
3192/// Run integrity verification on the database.
3193pub fn verify_integrity_sync(
3194    conn: &Connection,
3195    mode: VerifyMode,
3196) -> Result<IntegrityReport, MemoryError> {
3197    let mut issues = Vec::new();
3198
3199    let schema_version: u32 = conn
3200        .query_row("PRAGMA user_version", [], |row| row.get(0))
3201        .unwrap_or_else(|e| {
3202            issues.push(format!("failed to read schema version: {e}"));
3203            0
3204        });
3205    if schema_version > MAX_SCHEMA_VERSION {
3206        issues.push(format!(
3207            "schema version {} is ahead of supported {}",
3208            schema_version, MAX_SCHEMA_VERSION
3209        ));
3210    }
3211
3212    let fact_count: usize = conn
3213        .query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
3214        .unwrap_or_else(|e| {
3215            issues.push(format!("failed to count facts: {e}"));
3216            0
3217        });
3218    let chunk_count: usize = conn
3219        .query_row("SELECT COUNT(*) FROM chunks", [], |row| row.get(0))
3220        .unwrap_or_else(|e| {
3221            issues.push(format!("failed to count chunks: {e}"));
3222            0
3223        });
3224    let message_count: usize = conn
3225        .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
3226        .unwrap_or_else(|e| {
3227            issues.push(format!("failed to count messages: {e}"));
3228            0
3229        });
3230    let episode_count: usize = conn
3231        .query_row("SELECT COUNT(*) FROM episodes", [], |row| row.get(0))
3232        .unwrap_or_else(|e| {
3233            issues.push(format!("failed to count episodes: {e}"));
3234            0
3235        });
3236
3237    let facts_missing_embeddings: usize = conn
3238        .query_row(
3239            "SELECT COUNT(*) FROM facts WHERE embedding IS NULL",
3240            [],
3241            |row| row.get(0),
3242        )
3243        .unwrap_or_else(|e| {
3244            issues.push(format!("failed to count facts missing embeddings: {e}"));
3245            0
3246        });
3247    let chunks_missing_embeddings: usize = conn
3248        .query_row(
3249            "SELECT COUNT(*) FROM chunks WHERE embedding IS NULL",
3250            [],
3251            |row| row.get(0),
3252        )
3253        .unwrap_or_else(|e| {
3254            issues.push(format!("failed to count chunks missing embeddings: {e}"));
3255            0
3256        });
3257    let episodes_missing_embeddings: usize = conn
3258        .query_row(
3259            "SELECT COUNT(*) FROM episodes WHERE embedding IS NULL",
3260            [],
3261            |row| row.get(0),
3262        )
3263        .unwrap_or_else(|e| {
3264            issues.push(format!("failed to count episodes missing embeddings: {e}"));
3265            0
3266        });
3267
3268    if facts_missing_embeddings > 0 {
3269        issues.push(format!(
3270            "{} facts missing embeddings",
3271            facts_missing_embeddings
3272        ));
3273    }
3274    if chunks_missing_embeddings > 0 {
3275        issues.push(format!(
3276            "{} chunks missing embeddings",
3277            chunks_missing_embeddings
3278        ));
3279    }
3280    if episodes_missing_embeddings > 0 {
3281        issues.push(format!(
3282            "{} episodes missing embeddings",
3283            episodes_missing_embeddings
3284        ));
3285    }
3286
3287    let pending_ops = list_pending_index_ops(conn).unwrap_or_default();
3288    if !pending_ops.is_empty() {
3289        issues.push(format!(
3290            "{} pending HNSW sidecar ops queued in SQLite",
3291            pending_ops.len()
3292        ));
3293        for op in pending_ops.iter().take(5) {
3294            let op_kind = op.op_kind.as_str();
3295            let detail = match &op.last_error {
3296                Some(last_error) => format!(
3297                    "{} {} {} (attempts: {}, last_error: {})",
3298                    op.entity_type,
3299                    op.op_kind.as_str(),
3300                    op.item_key,
3301                    op.attempt_count,
3302                    last_error
3303                ),
3304                None => format!(
3305                    "{} {} {} (attempts: {})",
3306                    op.entity_type, op_kind, op.item_key, op.attempt_count
3307                ),
3308            };
3309            issues.push(format!("pending sidecar op: {detail}"));
3310        }
3311    }
3312
3313    if mode == VerifyMode::Full {
3314        let dims: usize = conn
3315            .query_row(
3316                "SELECT dimensions FROM embedding_metadata WHERE id = 1",
3317                [],
3318                |row| row.get(0),
3319            )
3320            .unwrap_or_else(|e| {
3321                issues.push(format!("failed to read embedding dimensions: {e}"));
3322                0
3323            });
3324
3325        verify_fts_drift(conn, "facts", "facts_rowid_map", fact_count, &mut issues);
3326        verify_fts_drift(conn, "chunks", "chunks_rowid_map", chunk_count, &mut issues);
3327        verify_fts_drift(
3328            conn,
3329            "messages",
3330            "messages_rowid_map",
3331            message_count,
3332            &mut issues,
3333        );
3334        verify_fts_drift(
3335            conn,
3336            "episodes",
3337            "episodes_rowid_map",
3338            episode_count,
3339            &mut issues,
3340        );
3341
3342        verify_blob_table(conn, "facts", "id", "embedding", dims, &mut issues)?;
3343        verify_blob_table(conn, "chunks", "id", "embedding", dims, &mut issues)?;
3344        verify_blob_table(conn, "messages", "id", "embedding", dims, &mut issues)?;
3345        verify_blob_table(
3346            conn,
3347            "episodes",
3348            "episode_id",
3349            "embedding",
3350            dims,
3351            &mut issues,
3352        )?;
3353
3354        verify_quantized_table(conn, "facts", "id", dims, &mut issues)?;
3355        verify_quantized_table(conn, "chunks", "id", dims, &mut issues)?;
3356        verify_quantized_table(conn, "messages", "id", dims, &mut issues)?;
3357        verify_quantized_table(conn, "episodes", "episode_id", dims, &mut issues)?;
3358
3359        verify_session_rows(conn, &mut issues)?;
3360        verify_message_rows(conn, &mut issues)?;
3361        verify_fact_rows(conn, &mut issues)?;
3362        verify_document_rows(conn, &mut issues)?;
3363        verify_episode_rows(conn, &mut issues)?;
3364
3365        let integrity_check: String = conn
3366            .query_row("PRAGMA integrity_check", [], |row| row.get(0))
3367            .unwrap_or_else(|_| "error".to_string());
3368        if integrity_check != "ok" {
3369            issues.push(format!("SQLite integrity_check: {}", integrity_check));
3370        }
3371    }
3372
3373    Ok(IntegrityReport {
3374        ok: issues.is_empty(),
3375        schema_version,
3376        fact_count,
3377        chunk_count,
3378        message_count,
3379        facts_missing_embeddings,
3380        chunks_missing_embeddings,
3381        issues,
3382    })
3383}
3384
3385/// Reconcile FTS indexes by rebuilding them from source data.
3386pub fn reconcile_fts(conn: &Connection) -> Result<(), MemoryError> {
3387    with_transaction(conn, |tx| {
3388        tx.execute_batch("DROP TABLE IF EXISTS facts_fts")?;
3389        tx.execute_batch("DELETE FROM facts_rowid_map")?;
3390        tx.execute_batch(
3391            "CREATE VIRTUAL TABLE facts_fts USING fts5(
3392                content,
3393                content='',
3394                content_rowid='rowid',
3395                tokenize='porter unicode61'
3396            )",
3397        )?;
3398        tx.execute_batch("INSERT INTO facts_rowid_map (fact_id) SELECT id FROM facts")?;
3399        tx.execute_batch(
3400            "INSERT INTO facts_fts (rowid, content)
3401             SELECT rm.rowid, f.content
3402             FROM facts_rowid_map rm
3403             JOIN facts f ON f.id = rm.fact_id",
3404        )?;
3405
3406        tx.execute_batch("DROP TABLE IF EXISTS chunks_fts")?;
3407        tx.execute_batch("DELETE FROM chunks_rowid_map")?;
3408        tx.execute_batch(
3409            "CREATE VIRTUAL TABLE chunks_fts USING fts5(
3410                content,
3411                content='',
3412                content_rowid='rowid',
3413                tokenize='porter unicode61'
3414            )",
3415        )?;
3416        tx.execute_batch("INSERT INTO chunks_rowid_map (chunk_id) SELECT id FROM chunks")?;
3417        tx.execute_batch(
3418            "INSERT INTO chunks_fts (rowid, content)
3419             SELECT rm.rowid, c.content
3420             FROM chunks_rowid_map rm
3421             JOIN chunks c ON c.id = rm.chunk_id",
3422        )?;
3423
3424        tx.execute_batch("DROP TABLE IF EXISTS messages_fts")?;
3425        tx.execute_batch("DELETE FROM messages_rowid_map")?;
3426        tx.execute_batch(
3427            "CREATE VIRTUAL TABLE messages_fts USING fts5(
3428                content,
3429                content='',
3430                content_rowid='rowid',
3431                tokenize='porter unicode61'
3432            )",
3433        )?;
3434        tx.execute_batch("INSERT INTO messages_rowid_map (message_id) SELECT id FROM messages")?;
3435        tx.execute_batch(
3436            "INSERT INTO messages_fts (rowid, content)
3437             SELECT rm.rowid, m.content
3438             FROM messages_rowid_map rm
3439             JOIN messages m ON m.id = rm.message_id",
3440        )?;
3441
3442        tx.execute_batch("DROP TABLE IF EXISTS episodes_fts")?;
3443        tx.execute_batch("DELETE FROM episodes_rowid_map")?;
3444        tx.execute_batch(
3445            "CREATE VIRTUAL TABLE episodes_fts USING fts5(
3446                content,
3447                content='',
3448                content_rowid='rowid',
3449                tokenize='porter unicode61'
3450            )",
3451        )?;
3452        tx.execute_batch(
3453            "INSERT INTO episodes_rowid_map (episode_id, document_id) SELECT episode_id, document_id FROM episodes",
3454        )?;
3455        tx.execute_batch(
3456            "INSERT INTO episodes_fts (rowid, content)
3457             SELECT rm.rowid, e.search_text
3458             FROM episodes_rowid_map rm
3459             JOIN episodes e ON e.episode_id = rm.episode_id",
3460        )?;
3461
3462        Ok(())
3463    })?;
3464
3465    tracing::info!("FTS indexes reconciled");
3466    Ok(())
3467}
3468
3469fn verify_fts_drift(
3470    conn: &Connection,
3471    label: &str,
3472    map_table: &str,
3473    source_count: usize,
3474    issues: &mut Vec<String>,
3475) {
3476    let table_exists: bool = conn
3477        .query_row(
3478            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
3479            params![map_table],
3480            |row| row.get(0),
3481        )
3482        .unwrap_or(false);
3483    if !table_exists {
3484        if source_count > 0 {
3485            issues.push(format!("{} rows exist but {} is missing", label, map_table));
3486        }
3487        return;
3488    }
3489
3490    let sql = format!("SELECT COUNT(*) FROM {}", map_table);
3491    let indexed_count: usize = conn.query_row(&sql, [], |row| row.get(0)).unwrap_or(0);
3492    if indexed_count != source_count {
3493        issues.push(format!(
3494            "FTS {} index drift: {} rows in map vs {} source rows",
3495            label, indexed_count, source_count
3496        ));
3497    }
3498}
3499
3500fn verify_blob_table(
3501    conn: &Connection,
3502    table: &'static str,
3503    id_column: &'static str,
3504    blob_column: &'static str,
3505    expected_dims: usize,
3506    issues: &mut Vec<String>,
3507) -> Result<(), MemoryError> {
3508    if expected_dims == 0 {
3509        return Ok(());
3510    }
3511
3512    let sql = format!(
3513        "SELECT CAST({id_column} AS TEXT), {blob_column} FROM {table} WHERE {blob_column} IS NOT NULL"
3514    );
3515    let mut stmt = conn.prepare(&sql)?;
3516    let rows = stmt.query_map([], |row| {
3517        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
3518    })?;
3519
3520    for row in rows {
3521        let (row_id, blob) = row?;
3522        match bytes_to_embedding(&blob) {
3523            Ok(embedding) if embedding.len() != expected_dims => issues.push(format!(
3524                "{}({}) has embedding dimension {} but expected {}",
3525                table,
3526                row_id,
3527                embedding.len(),
3528                expected_dims
3529            )),
3530            Ok(_) => {}
3531            Err(err) => issues.push(format!(
3532                "{}({}) invalid embedding blob: {}",
3533                table, row_id, err
3534            )),
3535        }
3536    }
3537
3538    Ok(())
3539}
3540
3541fn verify_quantized_table(
3542    conn: &Connection,
3543    table: &'static str,
3544    id_column: &'static str,
3545    expected_dims: usize,
3546    issues: &mut Vec<String>,
3547) -> Result<(), MemoryError> {
3548    if expected_dims == 0 {
3549        return Ok(());
3550    }
3551
3552    let sql = format!(
3553        "SELECT CAST({id_column} AS TEXT), embedding_q8 FROM {table} WHERE embedding IS NOT NULL"
3554    );
3555    let mut stmt = conn.prepare(&sql)?;
3556    let rows = stmt.query_map([], |row| {
3557        Ok((row.get::<_, String>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
3558    })?;
3559
3560    for row in rows {
3561        let (row_id, blob) = row?;
3562        match blob {
3563            Some(blob) => {
3564                if let Err(err) = unpack_quantized(&blob, expected_dims) {
3565                    issues.push(format!(
3566                        "{}({}) invalid quantized embedding: {}",
3567                        table, row_id, err
3568                    ));
3569                }
3570            }
3571            None => issues.push(format!("{}({}) missing quantized embedding", table, row_id)),
3572        }
3573    }
3574
3575    Ok(())
3576}
3577
3578fn verify_session_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3579    let mut stmt = conn.prepare("SELECT id, metadata FROM sessions WHERE metadata IS NOT NULL")?;
3580    let rows = stmt.query_map([], |row| {
3581        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3582    })?;
3583    for row in rows {
3584        let (id, metadata) = row?;
3585        if let Err(err) = parse_optional_json("sessions", &id, "metadata", Some(&metadata)) {
3586            issues.push(err.to_string());
3587        }
3588    }
3589    Ok(())
3590}
3591
3592fn verify_message_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3593    let mut stmt = conn.prepare("SELECT id, role, metadata FROM messages")?;
3594    let rows = stmt.query_map([], |row| {
3595        Ok((
3596            row.get::<_, i64>(0)?,
3597            row.get::<_, String>(1)?,
3598            row.get::<_, Option<String>>(2)?,
3599        ))
3600    })?;
3601    for row in rows {
3602        let (id, role, metadata) = row?;
3603        let row_id = id.to_string();
3604        if let Err(err) = parse_role("messages", &row_id, &role) {
3605            issues.push(err.to_string());
3606        }
3607        if let Err(err) = parse_optional_json("messages", &row_id, "metadata", metadata.as_deref())
3608        {
3609            issues.push(err.to_string());
3610        }
3611    }
3612    Ok(())
3613}
3614
3615fn verify_fact_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3616    let mut stmt = conn.prepare("SELECT id, metadata FROM facts WHERE metadata IS NOT NULL")?;
3617    let rows = stmt.query_map([], |row| {
3618        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3619    })?;
3620    for row in rows {
3621        let (id, metadata) = row?;
3622        if let Err(err) = parse_optional_json("facts", &id, "metadata", Some(&metadata)) {
3623            issues.push(err.to_string());
3624        }
3625    }
3626    Ok(())
3627}
3628
3629fn verify_document_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3630    let mut stmt = conn.prepare("SELECT id, metadata FROM documents WHERE metadata IS NOT NULL")?;
3631    let rows = stmt.query_map([], |row| {
3632        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3633    })?;
3634    for row in rows {
3635        let (id, metadata) = row?;
3636        if let Err(err) = parse_optional_json("documents", &id, "metadata", Some(&metadata)) {
3637            issues.push(err.to_string());
3638        }
3639    }
3640    Ok(())
3641}
3642
3643fn verify_episode_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3644    let mut stmt = conn.prepare(
3645        "SELECT episode_id, cause_ids, outcome, verification_status
3646         FROM episodes",
3647    )?;
3648    let rows = stmt.query_map([], |row| {
3649        Ok((
3650            row.get::<_, String>(0)?,
3651            row.get::<_, String>(1)?,
3652            row.get::<_, String>(2)?,
3653            row.get::<_, String>(3)?,
3654        ))
3655    })?;
3656    for row in rows {
3657        let (episode_id, cause_ids, outcome, verification_status) = row?;
3658        if let Err(err) = parse_string_list_json("episodes", &episode_id, "cause_ids", &cause_ids) {
3659            issues.push(err.to_string());
3660        }
3661        if let Err(err) = parse_episode_outcome(&episode_id, &outcome) {
3662            issues.push(err.to_string());
3663        }
3664        if let Err(err) = parse_verification_status(&episode_id, &verification_status) {
3665            issues.push(err.to_string());
3666        }
3667    }
3668    Ok(())
3669}
3670
3671#[derive(Debug, Clone)]
3672pub(crate) struct ProveKvPoolGenerationRow {
3673    pub generation: ProveKvPoolGenerationV1,
3674}
3675
3676#[allow(dead_code)] // retained for provekv pool diagnostics, not currently called
3677fn parse_provekv_status(value: &str) -> ProveKvPoolGenerationStatus {
3678    match value {
3679        "disabled" => ProveKvPoolGenerationStatus::Disabled,
3680        "missing" => ProveKvPoolGenerationStatus::Missing,
3681        "building" => ProveKvPoolGenerationStatus::Building,
3682        "ready" => ProveKvPoolGenerationStatus::Ready,
3683        "stale" => ProveKvPoolGenerationStatus::Stale,
3684        "failed" => ProveKvPoolGenerationStatus::Failed,
3685        _ => ProveKvPoolGenerationStatus::Failed,
3686    }
3687}
3688
3689fn provekv_generation_from_row(
3690    row: &rusqlite::Row<'_>,
3691) -> rusqlite::Result<ProveKvPoolGenerationRow> {
3692    let created_at: String = row.get(9)?;
3693    Ok(ProveKvPoolGenerationRow {
3694        generation: ProveKvPoolGenerationV1 {
3695            schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3696            generation_id: row.get(0)?,
3697            embedding_snapshot_digest: row.get(1)?,
3698            source_digest: row.get(2)?,
3699            pool_manifest_digest: row.get(3)?,
3700            codec_family: row.get(4)?,
3701            codec_profile: row.get(5)?,
3702            vector_dim: row.get::<_, i64>(6)? as usize,
3703            item_count: row.get::<_, i64>(7)? as usize,
3704            payload_bytes: row.get::<_, i64>(8)? as u64,
3705            created_at: DateTime::parse_from_rfc3339(&created_at)
3706                .map(|dt| dt.with_timezone(&Utc))
3707                .map_err(|err| {
3708                    rusqlite::Error::FromSqlConversionFailure(
3709                        9,
3710                        rusqlite::types::Type::Text,
3711                        Box::new(err),
3712                    )
3713                })?,
3714        },
3715    })
3716}
3717
3718#[allow(dead_code)]
3719pub(crate) fn insert_provekv_pool_generation(
3720    conn: &Connection,
3721    generation: &ProveKvPoolGenerationV1,
3722    payload: &[u8],
3723    item_map: &[ProveKvPoolItemMapEntryV1],
3724) -> Result<(), MemoryError> {
3725    let tx = conn.unchecked_transaction()?;
3726    tx.execute(
3727        "INSERT OR REPLACE INTO provekv_pool_generations
3728         (generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3729          codec_family, codec_profile, vector_dim, item_count, payload_bytes, payload,
3730          status, failure_reason, created_at)
3731         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'ready', NULL, ?11)",
3732        params![
3733            generation.generation_id,
3734            generation.embedding_snapshot_digest,
3735            generation.source_digest,
3736            generation.pool_manifest_digest,
3737            generation.codec_family,
3738            generation.codec_profile,
3739            generation.vector_dim as i64,
3740            generation.item_count as i64,
3741            generation.payload_bytes as i64,
3742            payload,
3743            generation.created_at.to_rfc3339(),
3744        ],
3745    )?;
3746    tx.execute(
3747        "DELETE FROM provekv_pool_item_map WHERE generation_id = ?1",
3748        params![generation.generation_id],
3749    )?;
3750    for entry in item_map {
3751        tx.execute(
3752            "INSERT INTO provekv_pool_item_map
3753             (generation_id, item_id, source_type, pool_index, embedding_digest)
3754             VALUES (?1, ?2, ?3, ?4, ?5)",
3755            params![
3756                entry.generation_id,
3757                entry.item_id,
3758                entry.source_type,
3759                entry.pool_index as i64,
3760                entry.embedding_digest,
3761            ],
3762        )?;
3763    }
3764    tx.commit()?;
3765    Ok(())
3766}
3767
3768pub(crate) fn latest_ready_provekv_pool_generation(
3769    conn: &Connection,
3770) -> Result<Option<ProveKvPoolGenerationRow>, MemoryError> {
3771    conn.query_row(
3772        "SELECT generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3773                codec_family, codec_profile, vector_dim, item_count, payload_bytes, created_at
3774         FROM provekv_pool_generations
3775         WHERE status = 'ready'
3776         ORDER BY created_at DESC
3777         LIMIT 1",
3778        [],
3779        provekv_generation_from_row,
3780    )
3781    .optional()
3782    .map_err(MemoryError::from)
3783}
3784
3785pub(crate) fn load_provekv_pool_payload(
3786    conn: &Connection,
3787    generation_id: &str,
3788) -> Result<Vec<u8>, MemoryError> {
3789    conn.query_row(
3790        "SELECT payload FROM provekv_pool_generations WHERE generation_id = ?1",
3791        params![generation_id],
3792        |row| row.get(0),
3793    )
3794    .map_err(MemoryError::from)
3795}
3796
3797pub(crate) fn load_provekv_pool_item_map(
3798    conn: &Connection,
3799    generation_id: &str,
3800) -> Result<Vec<ProveKvPoolItemMapEntryV1>, MemoryError> {
3801    let mut stmt = conn.prepare(
3802        "SELECT generation_id, item_id, source_type, pool_index, embedding_digest
3803         FROM provekv_pool_item_map
3804         WHERE generation_id = ?1
3805         ORDER BY pool_index ASC",
3806    )?;
3807    let rows = stmt.query_map(params![generation_id], |row| {
3808        Ok(ProveKvPoolItemMapEntryV1 {
3809            generation_id: row.get(0)?,
3810            item_id: row.get(1)?,
3811            source_type: row.get(2)?,
3812            pool_index: row.get::<_, i64>(3)? as usize,
3813            embedding_digest: row.get(4)?,
3814        })
3815    })?;
3816    let mut entries = Vec::new();
3817    for row in rows {
3818        entries.push(row?);
3819    }
3820    Ok(entries)
3821}
3822
3823#[allow(dead_code)]
3824pub(crate) fn mark_provekv_pool_generation_failed(
3825    conn: &Connection,
3826    generation_id: &str,
3827    reason: &str,
3828) -> Result<(), MemoryError> {
3829    conn.execute(
3830        "UPDATE provekv_pool_generations SET status = 'failed', failure_reason = ?2 WHERE generation_id = ?1",
3831        params![generation_id, reason],
3832    )?;
3833    Ok(())
3834}
3835
3836#[allow(dead_code)] // retained for provekv pool diagnostics, not currently called
3837pub(crate) fn provekv_pool_artifact_status(
3838    conn: &Connection,
3839) -> Result<ProveKvPoolArtifactStatusV1, MemoryError> {
3840    let row = conn
3841        .query_row(
3842            "SELECT generation_id, embedding_snapshot_digest, pool_manifest_digest,
3843                    item_count, payload_bytes, status, failure_reason
3844             FROM provekv_pool_generations
3845             ORDER BY created_at DESC
3846             LIMIT 1",
3847            [],
3848            |row| {
3849                Ok((
3850                    row.get::<_, String>(0)?,
3851                    row.get::<_, String>(1)?,
3852                    row.get::<_, String>(2)?,
3853                    row.get::<_, i64>(3)?,
3854                    row.get::<_, i64>(4)?,
3855                    row.get::<_, String>(5)?,
3856                    row.get::<_, Option<String>>(6)?,
3857                ))
3858            },
3859        )
3860        .optional()?;
3861    Ok(match row {
3862        Some((
3863            generation_id,
3864            snapshot_digest,
3865            manifest_digest,
3866            item_count,
3867            payload_bytes,
3868            status,
3869            reason,
3870        )) => ProveKvPoolArtifactStatusV1 {
3871            status: parse_provekv_status(&status),
3872            generation_id: Some(generation_id),
3873            embedding_snapshot_digest: Some(snapshot_digest),
3874            pool_manifest_digest: Some(manifest_digest),
3875            item_count: item_count as usize,
3876            payload_bytes: payload_bytes as u64,
3877            reason,
3878        },
3879        None => ProveKvPoolArtifactStatusV1 {
3880            status: ProveKvPoolGenerationStatus::Missing,
3881            generation_id: None,
3882            embedding_snapshot_digest: None,
3883            pool_manifest_digest: None,
3884            item_count: 0,
3885            payload_bytes: 0,
3886            reason: Some("provekv_pool_generation_not_materialized".into()),
3887        },
3888    })
3889}
3890
3891#[cfg(test)]
3892mod provekv_pool_generation_db_tests {
3893    use super::*;
3894
3895    fn test_conn() -> Connection {
3896        let conn = Connection::open_in_memory().expect("in-memory db opens");
3897        conn.execute_batch(MIGRATION_V24)
3898            .expect("proveKV schema migration applies");
3899        conn
3900    }
3901
3902    fn generation(id: &str) -> ProveKvPoolGenerationV1 {
3903        ProveKvPoolGenerationV1 {
3904            schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3905            generation_id: id.to_string(),
3906            embedding_snapshot_digest: "blake3:snapshot".to_string(),
3907            source_digest: "blake3:source".to_string(),
3908            pool_manifest_digest: "blake3:manifest".to_string(),
3909            codec_family: "provekv_pool".to_string(),
3910            codec_profile: "semantic-memory-f32-derived-candidate-v1".to_string(),
3911            vector_dim: 4,
3912            item_count: 2,
3913            payload_bytes: 3,
3914            created_at: Utc::now(),
3915        }
3916    }
3917
3918    #[test]
3919    fn provekv_pool_generation_roundtrips_and_cascades_item_map() {
3920        let conn = test_conn();
3921        let gen = generation("gen-1");
3922        let item_map = vec![
3923            ProveKvPoolItemMapEntryV1 {
3924                generation_id: gen.generation_id.clone(),
3925                item_id: "fact-1".to_string(),
3926                source_type: "fact".to_string(),
3927                pool_index: 0,
3928                embedding_digest: "blake3:item-1".to_string(),
3929            },
3930            ProveKvPoolItemMapEntryV1 {
3931                generation_id: gen.generation_id.clone(),
3932                item_id: "fact-2".to_string(),
3933                source_type: "fact".to_string(),
3934                pool_index: 1,
3935                embedding_digest: "blake3:item-2".to_string(),
3936            },
3937        ];
3938        insert_provekv_pool_generation(&conn, &gen, &[1, 2, 3], &item_map).unwrap();
3939
3940        let latest = latest_ready_provekv_pool_generation(&conn)
3941            .unwrap()
3942            .expect("latest ready generation");
3943        assert_eq!(latest.generation.generation_id, gen.generation_id);
3944        assert_eq!(
3945            load_provekv_pool_payload(&conn, "gen-1").unwrap(),
3946            vec![1, 2, 3]
3947        );
3948        assert_eq!(
3949            load_provekv_pool_item_map(&conn, "gen-1").unwrap(),
3950            item_map
3951        );
3952
3953        mark_provekv_pool_generation_failed(&conn, "gen-1", "boom").unwrap();
3954        let status = provekv_pool_artifact_status(&conn).unwrap();
3955        assert_eq!(status.status, ProveKvPoolGenerationStatus::Failed);
3956        assert_eq!(status.reason.as_deref(), Some("boom"));
3957
3958        conn.execute(
3959            "DELETE FROM provekv_pool_generations WHERE generation_id = 'gen-1'",
3960            [],
3961        )
3962        .unwrap();
3963        let count: i64 = conn
3964            .query_row("SELECT COUNT(*) FROM provekv_pool_item_map", [], |row| {
3965                row.get(0)
3966            })
3967            .unwrap();
3968        assert_eq!(count, 0);
3969    }
3970}