Skip to main content

kimetsu_brain/
schema.rs

1use rusqlite::Connection;
2
3use kimetsu_core::KimetsuResult;
4
5/// Apply performance-tuning SQLite pragmas to `conn`.
6///
7/// Safe on both read-write AND read-only connections: pragmas that cannot
8/// be set on a read-only DB (WAL mode, mmap_size) are skipped when they
9/// error, so the same function is called unconditionally from every open path.
10///
11/// Pragmas set:
12/// - `cache_size = -65536`      → 64 MiB page cache (negative = KiB)
13/// - `mmap_size = 268435456`    → 256 MiB memory-mapped I/O window
14/// - `synchronous = NORMAL`     → safe under WAL; avoids full fsync per commit
15/// - `temp_store = MEMORY`      → keep temp tables / sort buffers in RAM
16///
17/// `journal_mode = WAL` and `busy_timeout` are set by `create_baseline`
18/// (the read-write init path); they are NOT repeated here because
19/// `PRAGMA journal_mode` is a structural change that errors on read-only
20/// connections (the mode is already persisted in the DB file header).
21pub fn apply_pragmas(conn: &Connection) -> KimetsuResult<()> {
22    // cache_size and temp_store are safe on any connection.
23    conn.pragma_update(None, "cache_size", -65536_i64)?;
24    conn.pragma_update(None, "temp_store", "MEMORY")?;
25
26    // mmap_size and synchronous may fail on a read-only connection opened
27    // against a DB that's being written by another process in WAL mode.
28    // Best-effort: ignore errors from these two.
29    let _ = conn.pragma_update(None, "mmap_size", 268_435_456_i64);
30    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
31
32    Ok(())
33}
34
35pub fn initialize(conn: &Connection) -> KimetsuResult<()> {
36    apply_pragmas(conn)?;
37    create_baseline(conn)?;
38    crate::migrate::run_migrations(conn)?;
39
40    // T3c: the old brute-force `memory_vec` vec0 virtual table is gone (usearch
41    // supersedes it). Best-effort drop to reclaim space in upgraded brains.
42    //
43    // Best-effort: a vec0 vtable can't be dropped without the (now-removed)
44    // sqlite-vec module loaded, so this DROP raises "no such module: vec0" on
45    // upgraded brains. We deliberately ignore the Result so that error can NEVER
46    // propagate and break connection-open. An orphaned, never-accessed
47    // memory_vec is harmless — SQLite loads a vtable module lazily, only on
48    // access, and nothing in the codebase queries memory_vec anymore. New brains
49    // never create it.
50    let _ = conn.execute_batch("DROP TABLE IF EXISTS memory_vec;");
51
52    Ok(())
53}
54
55/// Test seam: exposes `create_baseline` so integration tests in sibling
56/// modules can build a v1 DB without going through the full `initialize`
57/// (which would run all migrations and advance to the current version).
58#[cfg(test)]
59pub fn create_baseline_for_test(conn: &Connection) -> KimetsuResult<()> {
60    create_baseline(conn)
61}
62
63/// Create the baseline v1 schema (pragmas + all tables/indexes/FTS as of the
64/// original v1 shape). Seeds `schema_info` with version **1** so the migration
65/// runner knows where to start. On an existing DB every CREATE is a no-op
66/// (`IF NOT EXISTS`).
67fn create_baseline(conn: &Connection) -> KimetsuResult<()> {
68    conn.pragma_update(None, "journal_mode", "WAL")?;
69    conn.pragma_update(None, "busy_timeout", 15_000)?;
70
71    conn.execute_batch(
72        "
73        CREATE TABLE IF NOT EXISTS schema_info (
74            key TEXT PRIMARY KEY,
75            value INTEGER NOT NULL
76        );
77
78        INSERT OR IGNORE INTO schema_info (key, value)
79        VALUES ('kimetsu_schema_version', 1);
80
81        CREATE TABLE IF NOT EXISTS runs (
82            run_id TEXT PRIMARY KEY,
83            project_id TEXT NOT NULL,
84            task TEXT NOT NULL,
85            started_at TEXT NOT NULL,
86            ended_at TEXT,
87            terminal_kind TEXT,
88            model TEXT,
89            total_cost_usd REAL NOT NULL DEFAULT 0
90        );
91
92        CREATE TABLE IF NOT EXISTS events (
93            event_id TEXT PRIMARY KEY,
94            run_id TEXT NOT NULL,
95            ts TEXT NOT NULL,
96            kind TEXT NOT NULL,
97            schema_version INTEGER NOT NULL,
98            payload_json TEXT NOT NULL,
99            origin TEXT,
100            hlc TEXT
101        );
102
103        CREATE INDEX IF NOT EXISTS idx_events_run_ts ON events (run_id, ts);
104        CREATE INDEX IF NOT EXISTS idx_events_kind_ts ON events (kind, ts);
105
106        CREATE TABLE IF NOT EXISTS sources (
107            source_id TEXT PRIMARY KEY,
108            kind TEXT NOT NULL,
109            ref TEXT NOT NULL,
110            hash TEXT,
111            added_at TEXT NOT NULL
112        );
113
114        CREATE TABLE IF NOT EXISTS memories (
115            memory_id TEXT PRIMARY KEY,
116            scope TEXT NOT NULL,
117            kind TEXT NOT NULL,
118            text TEXT NOT NULL,
119            normalized_text TEXT NOT NULL,
120            confidence REAL NOT NULL,
121            source_event_id TEXT,
122            provenance_snapshot_json TEXT NOT NULL,
123            created_at TEXT NOT NULL,
124            last_used_at TEXT,
125            use_count INTEGER NOT NULL DEFAULT 0,
126            usefulness_score REAL NOT NULL DEFAULT 0.0,
127            invalidated_at TEXT,
128            invalidated_reason TEXT
129        );
130
131        CREATE INDEX IF NOT EXISTS idx_memories_scope_kind_norm
132            ON memories (scope, kind, normalized_text);
133        CREATE TABLE IF NOT EXISTS memory_proposals (
134            proposal_id TEXT PRIMARY KEY,
135            run_id TEXT NOT NULL,
136            scope TEXT NOT NULL,
137            kind TEXT NOT NULL,
138            text TEXT NOT NULL,
139            rationale TEXT NOT NULL,
140            proposed_confidence REAL NOT NULL,
141            source_event_ids_json TEXT NOT NULL,
142            status TEXT NOT NULL,
143            decided_at TEXT,
144            decided_by TEXT,
145            decided_reason TEXT
146        );
147
148        CREATE INDEX IF NOT EXISTS idx_memory_proposals_status_run
149            ON memory_proposals (status, run_id);
150
151        CREATE TABLE IF NOT EXISTS repo_files (
152            repo_root TEXT NOT NULL,
153            path TEXT NOT NULL,
154            hash TEXT NOT NULL,
155            size INTEGER NOT NULL,
156            mtime TEXT NOT NULL,
157            language_guess TEXT NOT NULL,
158            snippet TEXT NOT NULL,
159            PRIMARY KEY (repo_root, path)
160        );
161
162        CREATE INDEX IF NOT EXISTS idx_repo_files_language
163            ON repo_files (repo_root, language_guess);
164
165        CREATE TABLE IF NOT EXISTS repo_manifests (
166            repo_root TEXT NOT NULL,
167            manifest_path TEXT NOT NULL,
168            manifest_kind TEXT NOT NULL,
169            parsed_summary_json TEXT NOT NULL,
170            hash TEXT NOT NULL,
171            mtime TEXT NOT NULL,
172            PRIMARY KEY (repo_root, manifest_path)
173        );
174
175        CREATE VIRTUAL TABLE IF NOT EXISTS repo_files_fts
176            USING fts5(repo_root, path, snippet, language_guess);
177
178        CREATE VIRTUAL TABLE IF NOT EXISTS repo_manifests_fts
179            USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
180
181        CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
182            USING fts5(memory_id UNINDEXED, text, kind, scope);
183        ",
184    )?;
185    Ok(())
186}
187
188/// The v1→v2 migration: folds every historical in-place patch
189/// (additive columns, citations/conflicts tables, FTS reshapes) into one
190/// idempotent step. Real-world DBs were all stamped v1, so this brings
191/// them — and freshly-created baselines — to the v2 shape.
192///
193/// NOTE: this function runs INSIDE a transaction owned by the migration
194/// runner. Do NOT issue BEGIN/COMMIT here.
195pub(crate) fn migrate_v1_to_v2(conn: &Connection) -> KimetsuResult<()> {
196    // In-place column additions for v0.1 brain.db files predating each
197    // column. Each ALTER is idempotent: we ignore the duplicate-column error
198    // so an upgraded binary opens an older brain.db without forcing a
199    // `kimetsu brain rebuild`.
200    add_column_if_missing(conn, "memory_proposals", "decided_reason TEXT")?;
201    // MP-4a: usefulness_score tracks the net outcome correlation of each
202    // memory. Incremented when a memory was in the context of a run.finished
203    // event; decremented for run.failed with category != "Gate". Used by the
204    // broker (MP-4b) to bias retrieval and by auto-accept (MP-4c) to shadow
205    // re-acceptance of low-usefulness patterns.
206    add_column_if_missing(
207        conn,
208        "memories",
209        "usefulness_score REAL NOT NULL DEFAULT 0.0",
210    )?;
211    // MP-4d: invalidated_at is set by `kimetsu brain memory invalidate` so
212    // the human reviewer can permanently retire a memory without rewriting
213    // the trace. The broker excludes invalidated rows from retrieval.
214    add_column_if_missing(conn, "memories", "invalidated_at TEXT")?;
215    add_column_if_missing(conn, "memories", "invalidated_reason TEXT")?;
216    // v0.4.2: hybrid retrieval scaffolding.
217    //   * `embedding`        — little-endian f32 BLOB, NULL on pre-v0.4.2 rows
218    //   * `embedding_model`  — opaque model id ("bge-small-en-v1.5",
219    //                          "stub-d8", "noop"), NULL when no embedding
220    //                          was produced (e.g. NoopEmbedder).
221    // Retrieval reads both: when `embedding` is non-NULL AND
222    // `embedding_model` matches the active embedder's id, the cosine
223    // score contributes to ranking. Otherwise the row is scored
224    // lexical-only (FTS) — exact v0.4.1 behavior, no regression.
225    add_column_if_missing(conn, "memories", "embedding BLOB")?;
226    add_column_if_missing(conn, "memories", "embedding_model TEXT")?;
227    // v0.5.1: timestamp of the most recent time this memory was
228    // cited AND the citing run ended in run.finished. Used by the
229    // broker's decay term: `effective = base * exp(-ln(2) *
230    // age_days / half_life)` so a memory that helped 6 months ago
231    // doesn't outvote one that helped yesterday.
232    //
233    // Distinct from `last_used_at` (bumped on every retrieval) —
234    // `last_useful_at` only tracks confirmed successful uses
235    // attributed via the v0.5.0 cite_memory tool.
236    //
237    // NULL on pre-v0.5.1 rows + on memories that have never been
238    // cited successfully. Retrieval falls back to `created_at` for
239    // the decay reference timestamp so brand-new memories don't
240    // get penalized for never having been cited yet.
241    add_column_if_missing(conn, "memories", "last_useful_at TEXT")?;
242    conn.execute_batch(
243        "
244        CREATE INDEX IF NOT EXISTS idx_memories_active_created
245            ON memories (invalidated_at, created_at);
246        ",
247    )?;
248    // v0.5.1: per-run, per-turn memory citation log.
249    //
250    // The model emits a `memory.cited` event (via the `cite_memory`
251    // tool) when it consciously leveraged a retrieved capsule. The
252    // projector mirrors each event into this table so the
253    // `kimetsu brain memory blame <run-id>` CLI + MCP tool can
254    // walk attribution without re-scanning the `events` table.
255    //
256    // Multiple citations per turn are allowed (a turn can use
257    // several memories). The PK includes `turn` so re-cites of the
258    // same memory across turns don't collide.
259    //
260    // Usefulness scoring upgrade (v0.5.1 sibling change in
261    // `projector::apply_run_finished` / `apply_run_failed`): cited
262    // memories get the full +/-1 delta; retrieved-but-not-cited
263    // memories get a weaker +/-0.1 — the strong signal goes to
264    // memories the model actually reasoned with, the weak signal
265    // stays for the silent passengers.
266    conn.execute_batch(
267        "
268        CREATE TABLE IF NOT EXISTS memory_citations (
269            run_id     TEXT NOT NULL,
270            memory_id  TEXT NOT NULL,
271            turn       INTEGER NOT NULL,
272            cited_at   TEXT NOT NULL,
273            rationale  TEXT,
274            PRIMARY KEY (run_id, memory_id, turn)
275        );
276        CREATE INDEX IF NOT EXISTS idx_citations_run
277            ON memory_citations (run_id);
278        CREATE INDEX IF NOT EXISTS idx_citations_memory
279            ON memory_citations (memory_id);
280        ",
281    )?;
282    // v0.5.2: conflict-detection log. When `add_memory` (or
283    // `add_user_memory`) inserts a new capsule whose embedding is
284    // close to an existing capsule in the same scope but whose
285    // normalized text differs, the conflict is logged here for
286    // operator review via `kimetsu brain memory conflicts`.
287    //
288    // We use `INSERT OR IGNORE` on (new_memory_id,
289    // existing_memory_id) so a re-scan over the same pair stays
290    // idempotent. `resolved_at` IS NULL marks an open conflict;
291    // `resolution` stores `'kept_new'`, `'kept_existing'`, or
292    // `'kept_both'` after operator decision.
293    //
294    // Embedder-only: conflict detection runs ONLY when a real
295    // embedder is available (cosine math requires it). NoopEmbedder
296    // builds silently skip the scan and never write to this table,
297    // so pre-v0.5.2 brain.db files opened by a lean build see no
298    // new rows.
299    conn.execute_batch(
300        "
301        CREATE TABLE IF NOT EXISTS memory_conflicts (
302            conflict_id        TEXT PRIMARY KEY,
303            new_memory_id      TEXT NOT NULL,
304            existing_memory_id TEXT NOT NULL,
305            scope              TEXT NOT NULL,
306            kind               TEXT NOT NULL,
307            similarity         REAL NOT NULL,
308            detected_at        TEXT NOT NULL,
309            resolved_at        TEXT,
310            resolution         TEXT,
311            UNIQUE (new_memory_id, existing_memory_id)
312        );
313        CREATE INDEX IF NOT EXISTS idx_conflicts_unresolved
314            ON memory_conflicts (resolved_at, detected_at);
315        CREATE INDEX IF NOT EXISTS idx_conflicts_new_memory
316            ON memory_conflicts (new_memory_id);
317
318        -- v2.6 #3 Slice B: concurrent-supersede conflicts surfaced during team
319        -- sync (a member superseded to two DIFFERENT survivors by concurrent
320        -- edits). HLC replay still picks a deterministic winner; this records the
321        -- collision for human review. A PROJECTION — cleared + repopulated by
322        -- rebuild. survivor_a < survivor_b (canonicalized) so it records once.
323        CREATE TABLE IF NOT EXISTS sync_conflicts (
324            member_id    TEXT NOT NULL,
325            survivor_a   TEXT NOT NULL,
326            survivor_b   TEXT NOT NULL,
327            detected_at  TEXT NOT NULL,
328            PRIMARY KEY (member_id, survivor_a, survivor_b)
329        );
330        ",
331    )?;
332    ensure_memories_fts_shape(conn)?;
333    ensure_repo_manifests_fts_shape(conn)?;
334
335    // v1.0 (Tier-1 perf): covering index for scope + embedding_model
336    // filtering in conflict detection and ANN pool fetch. Additive — the
337    // IF NOT EXISTS guard makes it idempotent on already-upgraded DBs.
338    conn.execute_batch(
339        "CREATE INDEX IF NOT EXISTS idx_memories_scope_model_active
340             ON memories (scope, embedding_model, invalidated_at);",
341    )?;
342
343    Ok(())
344}
345
346/// The v2→v3 migration: add `superseded_by` column to `memories`.
347///
348/// Superseded rows point at their survivor via this column.  Retrieval,
349/// listing, and export already filter `invalidated_at IS NULL`; this
350/// migration adds a companion `AND superseded_by IS NULL` guard to all
351/// such queries (applied in `context.rs`, `user_brain.rs`, and
352/// `project.rs`).  The FTS index and ANN index keep the same "remove on
353/// supersede" semantics as invalidation.
354///
355/// NOTE: this function runs INSIDE a transaction owned by the migration
356/// runner. Do NOT issue BEGIN/COMMIT here.
357pub(crate) fn migrate_v2_to_v3(conn: &Connection) -> KimetsuResult<()> {
358    add_column_if_missing(conn, "memories", "superseded_by TEXT")?;
359    conn.execute_batch(
360        "CREATE INDEX IF NOT EXISTS idx_memories_superseded
361             ON memories (superseded_by);",
362    )?;
363    Ok(())
364}
365
366/// The v3→v4 migration: add the `memory_edges` typed-edge projection table.
367///
368/// This table is the storage substrate for the S5.2 `GraphLiteBackend`.
369/// It is a **projection** (derivable from the event log) — `reset_projection`
370/// clears it and `rebuild_in_place` repopulates it by replaying events.
371///
372/// Edge types:
373/// * `supersedes`         — populated NOW from `memory.superseded` events.
374///   The surviving memory acquires a directed edge toward each member it
375///   absorbed.  Edge direction: `src_id` (survivor) → `dst_id` (member).
376/// * `refines`            — reserved; populated by the live write path when
377///   a `memory.accepted` event carries `refines_id` in the payload
378///   (Flagship 1 / Story 1.7).
379/// * `dead_end_of`        — reserved; populated when an episodic resume
380///   event closes a task-dead-end chain.
381/// * `decision_touches`   — reserved; decision memory → touched file paths.
382/// * `lesson_from`        — reserved; lesson memory → source memory / run.
383///
384/// NOTE: this function runs INSIDE a transaction owned by the migration
385/// runner.  Do NOT issue BEGIN/COMMIT here.
386pub(crate) fn migrate_v3_to_v4(conn: &Connection) -> KimetsuResult<()> {
387    conn.execute_batch(
388        "
389        CREATE TABLE IF NOT EXISTS memory_edges (
390            src_id      TEXT NOT NULL,
391            dst_id      TEXT NOT NULL,
392            edge_type   TEXT NOT NULL,
393            created_at  TEXT NOT NULL,
394            PRIMARY KEY (src_id, dst_id, edge_type)
395        );
396
397        CREATE INDEX IF NOT EXISTS idx_memory_edges_src
398            ON memory_edges (src_id, edge_type);
399
400        CREATE INDEX IF NOT EXISTS idx_memory_edges_dst
401            ON memory_edges (dst_id, edge_type);
402        ",
403    )?;
404    Ok(())
405}
406
407/// The v4→v5 migration: add the `work_episodes` per-repo episodic-resume
408/// projection table (Flagship 1, Story 1.3).
409///
410/// `work_episodes` is a **projection** derivable from `work.episode` events:
411/// `reset_projection` clears it and `rebuild_in_place` repopulates it.
412///
413/// NOTE: this function runs INSIDE a transaction owned by the migration
414/// runner.  Do NOT issue BEGIN/COMMIT here.
415pub(crate) fn migrate_v4_to_v5(conn: &Connection) -> KimetsuResult<()> {
416    crate::episode::create_work_episodes_table(conn)
417}
418
419/// The v5→v6 migration: add the `skill_proposals` table for Flagship 2
420/// Memory → Skill synthesis.
421///
422/// `skill_proposals` stores skill drafts (or candidate reports) produced
423/// by the skill-synthesis engine. Each row records:
424///   - a unique proposal id (ULID)
425///   - the draft SKILL.md content (NULL = report-only mode, no draft)
426///   - the suggested skill name / description
427///   - a JSON array of the source memory ids used to ground the draft
428///   - the trigger kind (`citations` or `cluster`)
429///   - citation count / cluster size that triggered synthesis
430///   - status: `pending` | `accepted` | `rejected`
431///   - when it was accepted and where the installed skill ended up
432///
433/// This table is a projection (not event-sourced) — proposals are created
434/// by the synthesis engine and consumed interactively; they are not
435/// replayed by `rebuild_in_place`.
436///
437/// NOTE: this function runs INSIDE a transaction owned by the migration
438/// runner. Do NOT issue BEGIN/COMMIT here.
439pub(crate) fn migrate_v5_to_v6(conn: &Connection) -> KimetsuResult<()> {
440    conn.execute_batch(
441        "
442        CREATE TABLE IF NOT EXISTS skill_proposals (
443            proposal_id   TEXT PRIMARY KEY,
444            skill_name    TEXT NOT NULL,
445            description   TEXT NOT NULL,
446            draft_content TEXT,
447            source_memory_ids_json TEXT NOT NULL DEFAULT '[]',
448            trigger_kind  TEXT NOT NULL,
449            trigger_count INTEGER NOT NULL DEFAULT 0,
450            status        TEXT NOT NULL DEFAULT 'pending',
451            decided_at    TEXT,
452            installed_path TEXT,
453            created_at    TEXT NOT NULL
454        );
455        CREATE INDEX IF NOT EXISTS idx_skill_proposals_status
456            ON skill_proposals (status, created_at);
457        ",
458    )?;
459    Ok(())
460}
461
462/// The v6→v7 migration: add `valid_from` and `valid_to` columns to `memories`
463/// for temporal validity modelling (Flagship 1 Pass A).
464///
465/// A memory with `valid_to` set to an ISO-8601 timestamp in the PAST is
466/// considered **expired** and is excluded from retrieval by default. Together
467/// with the existing `superseded_by IS NULL` guard this gives the retrieval
468/// pipeline a complete "is this fact still true?" filter.
469///
470/// Both columns are TEXT (ISO-8601 / RFC 3339) and nullable:
471///   * NULL `valid_from` → "valid since the memory was created" (no past-only guard).
472///   * NULL `valid_to`   → "valid indefinitely" (never expires).
473///   * Non-NULL `valid_to` with a value in the past → expired, excluded by default.
474///
475/// Populated by the `memory.temporal` event (projector: `apply_memory_temporal`).
476/// The columns survive a `reset_projection` + `rebuild_in_place` replay because
477/// the projector re-stamps them from the event log.
478///
479/// An index on `valid_to` is added so the retrieval WHERE clause
480///   `(valid_to IS NULL OR valid_to > <now>)`
481/// can use an index scan on the small subset of rows that are NOT NULL.
482///
483/// NOTE: this function runs INSIDE a transaction owned by the migration
484/// runner. Do NOT issue BEGIN/COMMIT here.
485pub(crate) fn migrate_v6_to_v7(conn: &Connection) -> KimetsuResult<()> {
486    add_column_if_missing(conn, "memories", "valid_from TEXT")?;
487    add_column_if_missing(conn, "memories", "valid_to TEXT")?;
488    conn.execute_batch(
489        "CREATE INDEX IF NOT EXISTS idx_memories_valid_to
490             ON memories (valid_to);",
491    )?;
492    Ok(())
493}
494
495/// v2.6 #3 (fleet write-safety): add a per-event `origin` column so every event
496/// records the device + agent that wrote it (`<machine_id>/<agent>`). Nullable;
497/// pre-v8 events read back as `origin = NULL` ("unknown"). Rebuild-safe and
498/// sync-ready (the origin is replicated verbatim).
499pub(crate) fn migrate_v7_to_v8(conn: &Connection) -> KimetsuResult<()> {
500    add_column_if_missing(conn, "events", "origin TEXT")?;
501    Ok(())
502}
503
504/// v2.6 #3 Slice B (team sync): add a per-event `hlc` column (Hybrid Logical
505/// Clock, canonical string) for globally-deterministic total-order replay.
506/// Existing rows are backfilled as `0000000000000.{rowid:010}.local` — `wall = 0`
507/// so all pre-v9 events sort BEFORE any new HLC event, ordered among themselves by
508/// `rowid` (their original insertion/causal order). This preserves a never-synced
509/// brain's projection exactly while giving every event a sortable HLC. Width (10)
510/// matches `Hlc::to_canonical` so backfilled and live HLCs compare consistently.
511pub(crate) fn migrate_v8_to_v9(conn: &Connection) -> KimetsuResult<()> {
512    add_column_if_missing(conn, "events", "hlc TEXT")?;
513    conn.execute_batch(
514        "UPDATE events
515         SET hlc = printf('%013d.%010d.local', 0, rowid)
516         WHERE hlc IS NULL;",
517    )?;
518    Ok(())
519}
520
521/// v2.5.2 consolidation v1: citations learn which QUERY they answered, and a
522/// derived `query_routes` table maps successful-query embeddings to the
523/// memories that answered them (built offline by `brain reinforce`, read at
524/// retrieval time as a bounded boost).
525pub(crate) fn migrate_v9_to_v10(conn: &Connection) -> KimetsuResult<()> {
526    // Guarded: synthetic/partial DBs (migration tests, tooling) may lack the
527    // table entirely; real brains always have it from the baseline.
528    let has_citations: bool = conn
529        .query_row(
530            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_citations'",
531            [],
532            |r| r.get::<_, i64>(0).map(|n| n > 0),
533        )
534        .unwrap_or(false);
535    if has_citations {
536        add_column_if_missing(conn, "memory_citations", "query TEXT")?;
537    }
538    conn.execute_batch(
539        "
540        CREATE TABLE IF NOT EXISTS query_routes (
541            query_norm      TEXT NOT NULL,
542            memory_id       TEXT NOT NULL,
543            cites           INTEGER NOT NULL DEFAULT 0,
544            last_cited_at   TEXT NOT NULL,
545            query_embedding BLOB,
546            embedding_model TEXT,
547            PRIMARY KEY (query_norm, memory_id)
548        );
549        CREATE INDEX IF NOT EXISTS idx_query_routes_memory
550            ON query_routes(memory_id);
551        ",
552    )?;
553    Ok(())
554}
555
556/// v2.6 (RFC phase 2c): `memory_entities` — tags and salient terms as rows.
557///
558/// Until now tags lived inline in the memory text as `[tags: …]` and were
559/// re-parsed on every read, and the tag boost was a substring match on the
560/// rendered summary. Promoting them to a table buys three things:
561///
562///   * the graph layer can find "other memories mentioning X" with an index
563///     lookup instead of an O(n²) scan over the whole corpus, which is what
564///     made edge-building a batch job rather than something the write path
565///     could afford;
566///   * `source` distinguishes an author-supplied tag from a salient term the
567///     extractor guessed, so ranking can weight them differently;
568///   * a tag match becomes an equality test rather than a substring one.
569///
570/// It is a pure projection of `memories.text` — `kimetsu brain rebuild`
571/// repopulates it from the event log, and nothing here needs its own events.
572pub(crate) fn migrate_v10_to_v11(conn: &Connection) -> KimetsuResult<()> {
573    conn.execute_batch(
574        "
575        CREATE TABLE IF NOT EXISTS memory_entities (
576            memory_id  TEXT NOT NULL,
577            entity     TEXT NOT NULL,
578            source     TEXT NOT NULL DEFAULT 'term',
579            PRIMARY KEY (memory_id, entity)
580        );
581        CREATE INDEX IF NOT EXISTS idx_memory_entities_entity
582            ON memory_entities(entity);
583        CREATE INDEX IF NOT EXISTS idx_memory_entities_memory
584            ON memory_entities(memory_id);
585        ",
586    )?;
587    // Backfill from the existing corpus so an upgraded brain has a usable
588    // entity index immediately, rather than only for memories written after
589    // the upgrade. Best-effort: on a synthetic or partial DB (migration tests,
590    // tooling) the `memories` table may not be there, and a missing backfill is
591    // recoverable with `kimetsu brain rebuild` — a failed migration is not.
592    let _ = crate::graph::reproject_all_entities(conn);
593    Ok(())
594}
595
596pub fn validate(conn: &Connection) -> KimetsuResult<()> {
597    // Apply performance pragmas on read-only connections too. The helper
598    // skips pragmas that error (journal_mode/mmap_size on some read-only
599    // opens), so this is always safe to call here.
600    apply_pragmas(conn)?;
601    use kimetsu_core::KIMETSU_SCHEMA_VERSION;
602    let current: i64 = conn.query_row(
603        "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
604        [],
605        |row| row.get(0),
606    )?;
607    let target = KIMETSU_SCHEMA_VERSION;
608    if current > target {
609        return Err(format!(
610            "brain.db schema version {current} was written by a newer Kimetsu (this binary expects {target}); upgrade Kimetsu"
611        )
612        .into());
613    }
614    if current < target {
615        return Err(Box::new(crate::migrate::SchemaNeedsMigration {
616            from: current,
617            to: target,
618        }));
619    }
620    Ok(())
621}
622
623fn add_column_if_missing(conn: &Connection, table: &str, column_def: &str) -> KimetsuResult<()> {
624    let column_name = column_def
625        .split_whitespace()
626        .next()
627        .ok_or("empty column definition")?;
628    let exists: bool = {
629        let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
630        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
631        let mut found = false;
632        for row in rows {
633            if row? == column_name {
634                found = true;
635                break;
636            }
637        }
638        found
639    };
640    if !exists {
641        conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column_def};"))?;
642    }
643    Ok(())
644}
645
646fn ensure_memories_fts_shape(conn: &Connection) -> KimetsuResult<()> {
647    if table_has_column(conn, "memories_fts", "memory_id")? {
648        return Ok(());
649    }
650    conn.execute_batch(
651        "
652        DROP TABLE IF EXISTS memories_fts;
653        CREATE VIRTUAL TABLE memories_fts
654            USING fts5(memory_id UNINDEXED, text, kind, scope);
655        INSERT INTO memories_fts (memory_id, text, kind, scope)
656            SELECT memory_id, text, kind, scope FROM memories;
657        ",
658    )?;
659    Ok(())
660}
661
662fn ensure_repo_manifests_fts_shape(conn: &Connection) -> KimetsuResult<()> {
663    if table_has_column(conn, "repo_manifests_fts", "parsed_summary_json")? {
664        return Ok(());
665    }
666    conn.execute_batch(
667        "
668        DROP TABLE IF EXISTS repo_manifests_fts;
669        CREATE VIRTUAL TABLE repo_manifests_fts
670            USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
671        INSERT INTO repo_manifests_fts (
672            repo_root, manifest_path, manifest_kind, parsed_summary_json
673        )
674            SELECT repo_root, manifest_path, manifest_kind, parsed_summary_json
675            FROM repo_manifests;
676        ",
677    )?;
678    Ok(())
679}
680
681fn table_has_column(conn: &Connection, table: &str, column: &str) -> KimetsuResult<bool> {
682    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
683    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
684    for row in rows {
685        if row? == column {
686            return Ok(true);
687        }
688    }
689    Ok(false)
690}
691
692/// Retain correction lineage and invalidate indexes across connections.
693pub fn migrate_v11_to_v12(conn: &Connection) -> KimetsuResult<()> {
694    conn.execute_batch("CREATE TABLE IF NOT EXISTS memory_revisions (
695        revision_id INTEGER PRIMARY KEY, memory_id TEXT NOT NULL,
696        event_id TEXT NOT NULL UNIQUE, text TEXT NOT NULL, kind TEXT NOT NULL,
697        known_at TEXT NOT NULL, effective_at TEXT NOT NULL,
698        confidence REAL NOT NULL, use_count INTEGER NOT NULL, usefulness_score REAL NOT NULL);
699        CREATE INDEX IF NOT EXISTS idx_memory_revisions_time ON memory_revisions(memory_id, known_at, effective_at);
700        CREATE TABLE IF NOT EXISTS corpus_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL);
701        INSERT OR IGNORE INTO corpus_revision VALUES (1,0);
702        CREATE TRIGGER IF NOT EXISTS corpus_insert AFTER INSERT ON memories BEGIN UPDATE corpus_revision SET revision=revision+1 WHERE id=1; END;
703        CREATE TRIGGER IF NOT EXISTS corpus_delete AFTER DELETE ON memories BEGIN UPDATE corpus_revision SET revision=revision+1 WHERE id=1; END;
704        CREATE TRIGGER IF NOT EXISTS corpus_update AFTER UPDATE OF embedding, embedding_model, text, invalidated_at, superseded_by ON memories BEGIN UPDATE corpus_revision SET revision=revision+1 WHERE id=1; END;")?;
705    Ok(())
706}
707
708/// Keep proposed applicability through review and replay.
709pub fn migrate_v12_to_v13(conn: &Connection) -> KimetsuResult<()> {
710    // Synthetic partial schemas used by migration tooling may omit proposals.
711    if !table_has_column(conn, "memory_proposals", "proposal_id")? {
712        return Ok(());
713    }
714    add_column_if_missing(conn, "memory_proposals", "valid_from TEXT")?;
715    add_column_if_missing(conn, "memory_proposals", "valid_to TEXT")?;
716    Ok(())
717}
718
719/// Optional stable task identity. Empty string retains the original legacy lane.
720pub(crate) fn migrate_v13_to_v14(conn: &Connection) -> KimetsuResult<()> {
721    crate::episode::create_work_episodes_table(conn)?;
722    add_column_if_missing(conn, "work_episodes", "identity TEXT NOT NULL DEFAULT ''")?;
723    conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_episodes_identity ON work_episodes(repo_root, identity, superseded_by)")?;
724    Ok(())
725}
726
727/// Derived structured evidence is replayable and never replaces memory text.
728pub(crate) fn migrate_v14_to_v15(conn: &Connection) -> KimetsuResult<()> {
729    conn.execute_batch(
730        "CREATE TABLE IF NOT EXISTS memory_facts (
731        memory_id TEXT NOT NULL, claim_revision TEXT NOT NULL, ordinal INTEGER NOT NULL,
732        source_event_id TEXT NOT NULL, source_digest TEXT NOT NULL, claim_json TEXT NOT NULL,
733        PRIMARY KEY(memory_id,claim_revision,ordinal));",
734    )?;
735    // Migration tools/tests can intentionally provide incomplete old schemas.
736    for column in ["memory_id", "text", "source_event_id"] {
737        if !table_has_column(conn, "memories", column)? {
738            return Ok(());
739        }
740    }
741    if !table_has_column(conn, "memory_revisions", "revision_id")? {
742        return Ok(());
743    }
744    crate::fact_store::backfill(conn)
745}
746
747// ---------------------------------------------------------------------------
748// Tests
749// ---------------------------------------------------------------------------
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use crate::migrate;
755    use rusqlite::Connection;
756
757    fn column_names(conn: &Connection, table: &str) -> Vec<String> {
758        let mut stmt = conn
759            .prepare(&format!("PRAGMA table_info({table})"))
760            .expect("prepare table_info");
761        stmt.query_map([], |row| row.get::<_, String>(1))
762            .expect("query_map")
763            .map(|r| r.expect("row"))
764            .collect()
765    }
766
767    fn table_exists(conn: &Connection, name: &str) -> bool {
768        let count: i64 = conn
769            .query_row(
770                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
771                [name],
772                |r| r.get(0),
773            )
774            .unwrap_or(0);
775        count > 0
776    }
777
778    // ------------------------------------------------------------------
779    // 1. Fresh init reaches current schema version with full shape
780    // ------------------------------------------------------------------
781    #[test]
782    fn fresh_init_reaches_current_version_with_full_shape() {
783        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
784        let conn = Connection::open_in_memory().expect("open_in_memory");
785        initialize(&conn).expect("initialize");
786
787        // Version must be at target.
788        assert_eq!(
789            migrate::current_version(&conn).expect("current_version"),
790            KIMETSU_SCHEMA_VERSION,
791            "fresh DB must be at current schema version after initialize"
792        );
793
794        // Post-migration columns exist on `memories`.
795        let mem_cols = column_names(&conn, "memories");
796        assert!(
797            mem_cols.contains(&"embedding".to_string()),
798            "memories must have `embedding` column"
799        );
800        assert!(
801            mem_cols.contains(&"embedding_model".to_string()),
802            "memories must have `embedding_model` column"
803        );
804        assert!(
805            mem_cols.contains(&"last_useful_at".to_string()),
806            "memories must have `last_useful_at` column"
807        );
808        // v3: superseded_by column
809        assert!(
810            mem_cols.contains(&"superseded_by".to_string()),
811            "memories must have `superseded_by` column after v3 migration"
812        );
813        // v7: temporal validity columns (Flagship 1 Pass A)
814        assert!(
815            mem_cols.contains(&"valid_from".to_string()),
816            "memories must have `valid_from` column after v7 migration"
817        );
818        assert!(
819            mem_cols.contains(&"valid_to".to_string()),
820            "memories must have `valid_to` column after v7 migration"
821        );
822
823        // Tables added by the migrations exist.
824        assert!(
825            table_exists(&conn, "memory_citations"),
826            "memory_citations table must exist"
827        );
828        assert!(
829            table_exists(&conn, "memory_conflicts"),
830            "memory_conflicts table must exist"
831        );
832        // v4: typed-edge projection table
833        assert!(
834            table_exists(&conn, "memory_edges"),
835            "memory_edges table must exist after v4 migration"
836        );
837        // v5: episodic resume table
838        assert!(
839            table_exists(&conn, "work_episodes"),
840            "work_episodes table must exist after v5 migration"
841        );
842        // v6: skill proposals table (Flagship 2 Memory → Skill synthesis)
843        assert!(
844            table_exists(&conn, "skill_proposals"),
845            "skill_proposals table must exist after v6 migration"
846        );
847    }
848
849    // ------------------------------------------------------------------
850    // 2. Idempotent re-run: run_migrations again after initialize is a no-op
851    // ------------------------------------------------------------------
852    #[test]
853    fn idempotent_rerun_preserves_data() {
854        let conn = Connection::open_in_memory().expect("open_in_memory");
855        initialize(&conn).expect("initialize");
856
857        // Insert a memories row.
858        conn.execute_batch(
859            "INSERT INTO memories (
860                memory_id, scope, kind, text, normalized_text,
861                confidence, provenance_snapshot_json, created_at,
862                use_count, usefulness_score
863             ) VALUES (
864                'mem-1', 'test', 'fact', 'hello world', 'hello world',
865                0.9, '{}', '2024-01-01T00:00:00Z',
866                0, 0.0
867             );",
868        )
869        .expect("insert row");
870
871        // Re-run migrations — must be a no-op at target.
872        let outcome = migrate::run_migrations(&conn).expect("second run_migrations");
873        assert_eq!(
874            outcome.applied,
875            Vec::<i64>::new(),
876            "second run_migrations must apply nothing"
877        );
878        assert_eq!(
879            migrate::current_version(&conn).expect("current_version"),
880            kimetsu_core::KIMETSU_SCHEMA_VERSION,
881            "version must still be at target"
882        );
883
884        // Data must be intact.
885        let text: String = conn
886            .query_row(
887                "SELECT text FROM memories WHERE memory_id = 'mem-1'",
888                [],
889                |r| r.get(0),
890            )
891            .expect("row must survive");
892        assert_eq!(text, "hello world");
893    }
894
895    // ------------------------------------------------------------------
896    // 3. Idempotent initialize: calling initialize twice succeeds, version stays at target
897    // ------------------------------------------------------------------
898    #[test]
899    fn idempotent_initialize_twice() {
900        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
901        let conn = Connection::open_in_memory().expect("open_in_memory");
902        initialize(&conn).expect("first initialize");
903        initialize(&conn).expect("second initialize must not error");
904        assert_eq!(
905            migrate::current_version(&conn).expect("current_version"),
906            KIMETSU_SCHEMA_VERSION,
907            "version must still be at target after double initialize"
908        );
909    }
910
911    // ------------------------------------------------------------------
912    // Fix 1: apply_pragmas sets the tuned cache_size on both RW and RO
913    // ------------------------------------------------------------------
914    #[test]
915    fn apply_pragmas_sets_cache_size_on_rw_connection() {
916        let conn = Connection::open_in_memory().expect("open_in_memory");
917        initialize(&conn).expect("initialize");
918        // After initialize (which calls apply_pragmas), cache_size must be -65536
919        // (the negative-KiB form we set). SQLite may return it as a page count
920        // (positive) or keep the -KiB form; we just assert it's not the default
921        // -2000 pages, which is what SQLite uses without any pragma_update.
922        let cache_size: i64 = conn
923            .pragma_query_value(None, "cache_size", |row| row.get(0))
924            .expect("cache_size query");
925        assert_ne!(
926            cache_size, -2000,
927            "cache_size must have been updated from the 2 MiB default, got {cache_size}"
928        );
929        // The tuned value should be a large negative number (KiB) or a large
930        // positive page count — either way not the stock default.
931        assert!(
932            !(-2000..=2000).contains(&cache_size),
933            "cache_size should reflect the 64 MiB tuning (not default -2000), got {cache_size}"
934        );
935    }
936
937    /// Fix 1: validate() (the read-only open path) also calls apply_pragmas.
938    /// We can't open a true read-only connection to an in-memory DB via OpenFlags,
939    /// so we exercise the helper directly and verify it doesn't error.
940    #[test]
941    fn apply_pragmas_does_not_error_on_in_memory_conn() {
942        let conn = Connection::open_in_memory().expect("open_in_memory");
943        apply_pragmas(&conn).expect("apply_pragmas must not error on a fresh in-memory conn");
944        let cache_size: i64 = conn
945            .pragma_query_value(None, "cache_size", |row| row.get(0))
946            .expect("cache_size");
947        assert!(
948            !(-2000..=2000).contains(&cache_size),
949            "apply_pragmas must update cache_size from the default, got {cache_size}"
950        );
951    }
952
953    // Helper: seed an in-memory conn with only schema_info at the given version.
954    fn seed_schema_info(version: i64) -> Connection {
955        let conn = Connection::open_in_memory().expect("open_in_memory");
956        conn.execute_batch(&format!(
957            "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
958             INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
959        ))
960        .expect("seed schema_info");
961        conn
962    }
963
964    // ------------------------------------------------------------------
965    // A5-1. validate Ok at target version
966    // ------------------------------------------------------------------
967    #[test]
968    fn validate_ok_at_target() {
969        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
970        let conn = seed_schema_info(KIMETSU_SCHEMA_VERSION);
971        validate(&conn).expect("validate at target must return Ok(())");
972    }
973
974    // ------------------------------------------------------------------
975    // A5-2. validate returns SchemaNeedsMigration for an older DB
976    // ------------------------------------------------------------------
977    #[test]
978    fn validate_returns_needs_migration_for_older_db() {
979        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
980        let conn = seed_schema_info(1);
981        let err = validate(&conn).expect_err("validate on v1 DB must return Err");
982        let snm = err
983            .downcast_ref::<migrate::SchemaNeedsMigration>()
984            .expect("error must downcast to SchemaNeedsMigration");
985        assert_eq!(
986            snm,
987            &migrate::SchemaNeedsMigration {
988                from: 1,
989                to: KIMETSU_SCHEMA_VERSION,
990            },
991            "SchemaNeedsMigration must carry the correct from/to versions"
992        );
993    }
994
995    // ------------------------------------------------------------------
996    // A5-v3. v2→v3 migration adds superseded_by + index
997    // ------------------------------------------------------------------
998    #[test]
999    fn v2_to_v3_migration_adds_superseded_by() {
1000        let conn = Connection::open_in_memory().expect("open_in_memory");
1001        // Seed a v2 DB manually (baseline + v1→v2 migration, no v2→v3).
1002        create_baseline(&conn).expect("create_baseline");
1003        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1004        conn.execute(
1005            "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
1006            [],
1007        )
1008        .expect("set v2");
1009
1010        // superseded_by must NOT exist yet.
1011        let cols_before = column_names(&conn, "memories");
1012        assert!(
1013            !cols_before.contains(&"superseded_by".to_string()),
1014            "superseded_by must not exist before v3 migration"
1015        );
1016
1017        // Run v2→v3.
1018        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1019
1020        // Now it must exist.
1021        let cols_after = column_names(&conn, "memories");
1022        assert!(
1023            cols_after.contains(&"superseded_by".to_string()),
1024            "superseded_by must exist after v3 migration"
1025        );
1026
1027        // Index must also exist.
1028        let idx_count: i64 = conn
1029            .query_row(
1030                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_superseded'",
1031                [],
1032                |r| r.get(0),
1033            )
1034            .expect("query index");
1035        assert_eq!(
1036            idx_count, 1,
1037            "idx_memories_superseded must exist after v3 migration"
1038        );
1039    }
1040
1041    // ------------------------------------------------------------------
1042    // A5-3. validate hard-errors (non-SchemaNeedsMigration) for a newer DB
1043    // ------------------------------------------------------------------
1044    #[test]
1045    fn validate_hard_errors_for_newer_db() {
1046        let conn = seed_schema_info(999);
1047        let err = validate(&conn).expect_err("validate on v999 DB must return Err");
1048        assert!(
1049            err.downcast_ref::<migrate::SchemaNeedsMigration>()
1050                .is_none(),
1051            "error for a newer DB must NOT downcast to SchemaNeedsMigration"
1052        );
1053        let msg = err.to_string();
1054        assert!(
1055            msg.contains("newer"),
1056            "error message must contain 'newer', got: {msg}"
1057        );
1058    }
1059
1060    // ------------------------------------------------------------------
1061    // S5.2-v4. v3→v4 migration adds memory_edges table + indexes
1062    // ------------------------------------------------------------------
1063    #[test]
1064    fn v3_to_v4_migration_adds_memory_edges() {
1065        let conn = Connection::open_in_memory().expect("open_in_memory");
1066        // Build a v3 DB (baseline + v1→v2 + v2→v3, no v3→v4).
1067        create_baseline(&conn).expect("create_baseline");
1068        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1069        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1070        conn.execute(
1071            "UPDATE schema_info SET value = 3 WHERE key = 'kimetsu_schema_version'",
1072            [],
1073        )
1074        .expect("set v3");
1075
1076        // memory_edges must NOT exist yet.
1077        assert!(
1078            !table_exists(&conn, "memory_edges"),
1079            "memory_edges must not exist before v4 migration"
1080        );
1081
1082        // Run v3→v4.
1083        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1084
1085        // Table must now exist.
1086        assert!(
1087            table_exists(&conn, "memory_edges"),
1088            "memory_edges must exist after v4 migration"
1089        );
1090
1091        // Indexes must exist.
1092        let src_idx: i64 = conn
1093            .query_row(
1094                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_src'",
1095                [],
1096                |r| r.get(0),
1097            )
1098            .expect("query idx_memory_edges_src");
1099        assert_eq!(src_idx, 1, "idx_memory_edges_src must exist");
1100
1101        let dst_idx: i64 = conn
1102            .query_row(
1103                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_dst'",
1104                [],
1105                |r| r.get(0),
1106            )
1107            .expect("query idx_memory_edges_dst");
1108        assert_eq!(dst_idx, 1, "idx_memory_edges_dst must exist");
1109    }
1110
1111    // ------------------------------------------------------------------
1112    // F1-v5. v4→v5 migration adds work_episodes table + indexes
1113    // ------------------------------------------------------------------
1114    #[test]
1115    fn v4_to_v5_migration_adds_work_episodes() {
1116        let conn = Connection::open_in_memory().expect("open_in_memory");
1117        // Build a v4 DB (baseline + v1→v2 + v2→v3 + v3→v4, no v4→v5).
1118        create_baseline(&conn).expect("create_baseline");
1119        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1120        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1121        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1122        conn.execute(
1123            "UPDATE schema_info SET value = 4 WHERE key = 'kimetsu_schema_version'",
1124            [],
1125        )
1126        .expect("set v4");
1127
1128        // work_episodes must NOT exist yet.
1129        assert!(
1130            !table_exists(&conn, "work_episodes"),
1131            "work_episodes must not exist before v5 migration"
1132        );
1133
1134        // Run v4→v5.
1135        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1136
1137        // Table must now exist.
1138        assert!(
1139            table_exists(&conn, "work_episodes"),
1140            "work_episodes must exist after v5 migration"
1141        );
1142
1143        // Repo-live index must exist.
1144        let idx: i64 = conn
1145            .query_row(
1146                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_episodes_repo_live'",
1147                [],
1148                |r| r.get(0),
1149            )
1150            .expect("query idx_episodes_repo_live");
1151        assert_eq!(idx, 1, "idx_episodes_repo_live must exist");
1152    }
1153
1154    // ------------------------------------------------------------------
1155    // F2-v6. v5→v6 migration adds skill_proposals table + index
1156    // ------------------------------------------------------------------
1157    #[test]
1158    fn v5_to_v6_migration_adds_skill_proposals() {
1159        let conn = Connection::open_in_memory().expect("open_in_memory");
1160        // Build a v5 DB (all prior migrations, no v5→v6).
1161        create_baseline(&conn).expect("create_baseline");
1162        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1163        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1164        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1165        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1166        conn.execute(
1167            "UPDATE schema_info SET value = 5 WHERE key = 'kimetsu_schema_version'",
1168            [],
1169        )
1170        .expect("set v5");
1171
1172        // skill_proposals must NOT exist yet.
1173        assert!(
1174            !table_exists(&conn, "skill_proposals"),
1175            "skill_proposals must not exist before v6 migration"
1176        );
1177
1178        // Run v5→v6.
1179        migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1180
1181        // Table must now exist.
1182        assert!(
1183            table_exists(&conn, "skill_proposals"),
1184            "skill_proposals must exist after v6 migration"
1185        );
1186
1187        // Status index must exist.
1188        let idx: i64 = conn
1189            .query_row(
1190                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_status'",
1191                [],
1192                |r| r.get(0),
1193            )
1194            .expect("query idx_skill_proposals_status");
1195        assert_eq!(
1196            idx, 1,
1197            "idx_skill_proposals_status must exist after v6 migration"
1198        );
1199    }
1200
1201    // ------------------------------------------------------------------
1202    // F1A-v7. v6→v7 migration adds valid_from + valid_to columns + index
1203    // ------------------------------------------------------------------
1204    #[test]
1205    fn v6_to_v7_migration_adds_temporal_validity_columns() {
1206        let conn = Connection::open_in_memory().expect("open_in_memory");
1207        // Build a v6 DB (all prior migrations, no v6→v7).
1208        create_baseline(&conn).expect("create_baseline");
1209        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1210        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1211        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1212        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1213        migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1214        conn.execute(
1215            "UPDATE schema_info SET value = 6 WHERE key = 'kimetsu_schema_version'",
1216            [],
1217        )
1218        .expect("set v6");
1219
1220        // valid_from and valid_to must NOT exist yet.
1221        let cols_before = column_names(&conn, "memories");
1222        assert!(
1223            !cols_before.contains(&"valid_from".to_string()),
1224            "valid_from must not exist before v7 migration"
1225        );
1226        assert!(
1227            !cols_before.contains(&"valid_to".to_string()),
1228            "valid_to must not exist before v7 migration"
1229        );
1230
1231        // Run v6→v7.
1232        migrate_v6_to_v7(&conn).expect("migrate_v6_to_v7");
1233
1234        // Columns must now exist.
1235        let cols_after = column_names(&conn, "memories");
1236        assert!(
1237            cols_after.contains(&"valid_from".to_string()),
1238            "valid_from must exist after v7 migration"
1239        );
1240        assert!(
1241            cols_after.contains(&"valid_to".to_string()),
1242            "valid_to must exist after v7 migration"
1243        );
1244
1245        // Index on valid_to must exist.
1246        let idx: i64 = conn
1247            .query_row(
1248                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_valid_to'",
1249                [],
1250                |r| r.get(0),
1251            )
1252            .expect("query idx_memories_valid_to");
1253        assert_eq!(
1254            idx, 1,
1255            "idx_memories_valid_to must exist after v7 migration"
1256        );
1257    }
1258}