Skip to main content

semantic_memory/
db.rs

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