---
name: Storage schema and migrations
type: architecture-map-page
slice: 05-core-storage-hotkey-build
last_verified: 2026/05/09
---

# Storage schema and migrations

> **Where you are:** [Architecture map](../README.md) → [Core, Storage, Hotkey, Build](README.md) → Storage schema and migrations

**Plain English summary.** The full database schema as currently shipped, plus the append-only history of migrations that brought it to v15. Each migration is one transaction. If a migration fails partway, the transaction rolls back so the schema never ends up half-applied. The schema is forward-only — there are no down-migrations.

## At a glance

- File: `crates/storage/src/migrations.rs` (1,185 LOC).
- External deps: `sqlx 0.8` (`SqlitePool`), `lumotia_core::error`.
- Public surface: `run_migrations(pool: &SqlitePool) -> Result<()>`. That is the only `pub` symbol.
- Schema head: **v15**.
- Consumers: called by `lumotia_storage::database::init` at `crates/storage/src/database.rs:54`.

## Migration runner contract

The migration registry is a `&[(i64, &str, &str)]` triple: `(version, description, sql)`. The runner is at `crates/storage/src/migrations.rs:525`:

```rust
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
    run_migrations_slice(pool, MIGRATIONS).await
}
```

`run_migrations_slice` (`migrations.rs:546`):

1. Ensures `schema_version (version INTEGER PRIMARY KEY, description TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT datetime('now'))` exists.
2. Reads `MAX(version)` from `schema_version`.
3. For each `(version, description, sql)` whose `version > current`:
    - Begin a `tx`.
    - Split the `sql` blob into individual statements via `split_statements` (a hand-rolled SQLite-trigger-aware splitter at `migrations.rs:483`).
    - Execute each statement against the transaction.
    - Insert the version row into `schema_version` inside the same transaction.
    - Commit.
4. Idempotent: re-running on a head schema does nothing.

Atomicity (one tx per migration) was added in response to the 2026-04-22 review (RB-02 / C3). Documented inline at `migrations.rs:548-565`. **Constraint:** future migrations must avoid SQLite operations that implicitly commit (`VACUUM`, `REINDEX`, `ATTACH`). If one is needed, split it into its own non-transactional migration — reviewer's job to flag.

## Schema head (v15)

The shape of every table at head, derived by replaying v1 → v15.

### `transcripts` — keyed `id TEXT PRIMARY KEY`

```sql
CREATE TABLE transcripts (
    id TEXT PRIMARY KEY,
    text TEXT NOT NULL DEFAULT '',
    source TEXT NOT NULL DEFAULT 'microphone',
    title TEXT,
    audio_path TEXT,
    duration REAL NOT NULL DEFAULT 0.0,
    engine TEXT,
    model_id TEXT,
    inference_ms INTEGER,
    sample_rate INTEGER,
    audio_channels INTEGER,
    format_mode TEXT,
    remove_fillers INTEGER NOT NULL DEFAULT 0,
    british_english INTEGER NOT NULL DEFAULT 0,
    anti_hallucination INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    -- v5 (transcripts_meta):
    starred INTEGER NOT NULL DEFAULT 0,
    manual_tags TEXT NOT NULL DEFAULT '',
    template TEXT NOT NULL DEFAULT '',
    language TEXT NOT NULL DEFAULT '',
    segments_json TEXT NOT NULL DEFAULT '',
    -- v8/v9 (profile FK):
    profile_id TEXT NOT NULL REFERENCES profiles(id),
    -- v14:
    llm_tags TEXT NOT NULL DEFAULT ''
);

CREATE INDEX idx_transcripts_created ON transcripts(created_at);
CREATE INDEX idx_transcripts_profile_id ON transcripts(profile_id);
CREATE INDEX idx_transcripts_profile_created ON transcripts(profile_id, created_at DESC);  -- v15
```

### `segments` — keyed `INTEGER PRIMARY KEY AUTOINCREMENT`

```sql
CREATE TABLE segments (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
    start_time REAL NOT NULL,
    end_time REAL NOT NULL,
    text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_segments_transcript ON segments(transcript_id);
```

(Note: today the engines store full segments in `transcripts.segments_json` rather than this table. The table is kept for backwards compatibility.)

### `transcripts_fts` — FTS5 virtual table (v2, rebuilt v9)

```sql
CREATE VIRTUAL TABLE transcripts_fts USING fts5(
    text, title,
    content='transcripts', content_rowid='rowid',
    tokenize='porter unicode61 remove_diacritics 2'
);
```

Plus three triggers (`transcripts_ai`, `transcripts_ad`, `transcripts_au`) that mirror inserts / deletes / updates from `transcripts` into the FTS index. See [`storage-fts5-search.md`](storage-fts5-search.md).

### `tasks`

```sql
CREATE TABLE tasks (
    id TEXT PRIMARY KEY,
    text TEXT NOT NULL,
    bucket TEXT NOT NULL DEFAULT 'inbox',
    list_id TEXT,
    effort TEXT,
    done INTEGER NOT NULL DEFAULT 0,
    done_at TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    source_transcript_id TEXT,
    -- v3:
    parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
    -- v4:
    notes TEXT NOT NULL DEFAULT '',
    -- v11:
    energy TEXT,  -- CHECK (energy IN ('high', 'medium', 'brain_dead') OR energy IS NULL)
    -- v13:
    auto_completed INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX idx_tasks_bucket ON tasks(bucket);
CREATE INDEX idx_tasks_transcript ON tasks(source_transcript_id);
CREATE INDEX idx_tasks_parent ON tasks(parent_task_id);
CREATE INDEX idx_tasks_energy_created ON tasks(energy, created_at);  -- v11
CREATE INDEX idx_tasks_done_at_auto_completed ON tasks(done_at, auto_completed);  -- v13
```

### `task_lists`

```sql
CREATE TABLE task_lists (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    built_in INTEGER NOT NULL DEFAULT 0,
    profile_id TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```

### `profiles` (v6)

```sql
CREATE TABLE profiles (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    initial_prompt TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```

Default profile seeded at id `00000000-0000-0000-0000-000000000001`. Two SQL triggers protect it:

```sql
CREATE TRIGGER trg_protect_default_profile_delete
    BEFORE DELETE ON profiles
    WHEN OLD.id = '00000000-0000-0000-0000-000000000001'
    BEGIN SELECT RAISE(ABORT, 'cannot delete the default profile'); END;

CREATE TRIGGER trg_protect_default_profile_rename
    BEFORE UPDATE OF id, name ON profiles
    WHEN OLD.id = '00000000-0000-0000-0000-000000000001'
    BEGIN SELECT RAISE(ABORT, 'cannot rename the default profile'); END;
```

### `profile_terms` (v6)

```sql
CREATE TABLE profile_terms (
    id TEXT PRIMARY KEY,
    profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
    term TEXT NOT NULL,
    note TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_profile_terms_profile_id ON profile_terms(profile_id);
```

UUIDs for `id` are generated as v4 random per `crates/storage/Cargo.toml`. (The `Cargo.toml` comment says "v7 random" but the feature flag is `["v4"]`. This is a known inconsistency between comment and reality. The code path uses `Uuid::new_v4()`.)

### `settings`

Plain key-value store. `key TEXT PRIMARY KEY, value TEXT NOT NULL`. The frontend's preferences blob lives at key `lumotia_preferences`.

### `error_log`

Append-only diagnostic log. Pruned by `prune_error_log` on app startup with a 90-day default. See [`storage-crud-settings-and-misc.md`](storage-crud-settings-and-misc.md).

### `feedback` (v10)

HITL thumbs + corrections, scoped to profile. See [`storage-crud-settings-and-misc.md`](storage-crud-settings-and-misc.md).

### `implementation_rules` (v12)

If-then automation rules. See [`storage-crud-settings-and-misc.md`](storage-crud-settings-and-misc.md).

### `schema_version`

Bookkeeping. Created by the migration runner itself.

## Migration history

15 migrations, all in `MIGRATIONS: &[(i64, &str, &str)]` at `crates/storage/src/migrations.rs:7`.

| v | Description | What changes |
|---|---|---|
| 1 | initial schema | Creates `transcripts`, `segments`, `tasks`, `task_lists`, `settings`, `error_log` plus 5 base indexes. |
| 2 | transcripts FTS5 + dictionary table | Creates `transcripts_fts` virtual table, three sync triggers, `dictionary` table (later removed). |
| 3 | micro-stepping: `parent_task_id` on tasks | `ALTER tasks ADD COLUMN parent_task_id` (with FK + cascade); `idx_tasks_parent`. |
| 4 | tasks_meta: notes column | `ALTER tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''`. |
| 5 | transcripts_meta | Adds `starred`, `manual_tags`, `template`, `language`, `segments_json` columns. Persists what was previously in `localStorage` `lumotia_history`. |
| 6 | profiles | Creates `profiles` and `profile_terms` tables; seeds the default profile; copies `dictionary` rows to the default profile's `profile_terms`; installs the two protection triggers. |
| 7 | drop_dictionary | Removes the legacy `dictionary` table and its index. Data has already been copied in v6. |
| 8 | transcript_profile_provenance | Adds `transcripts.profile_id` (nullable initially); `idx_transcripts_profile_id`. |
| 9 | transcript_profile_fk | **Table rebuild.** Reconciles orphaned `transcripts.profile_id` values to the default profile, then rebuilds `transcripts` and `segments` with the FK declared. The FTS table and triggers are dropped and recreated against the rebuilt `transcripts`. Driven by [`docs/issues/c4-transcript-profile-fk.md`](../../issues/c4-transcript-profile-fk.md). |
| 10 | feedback: HITL thumbs + correction capture | Creates `feedback` table; two indexes (target_type+rating, profile). |
| 11 | tasks: energy tagging | `ALTER tasks ADD COLUMN energy` with `CHECK` constraint; `idx_tasks_energy_created`. |
| 12 | implementation intentions | Creates `implementation_rules` table; `idx_implementation_rules_enabled_trigger`. |
| 13 | gamification: `auto_completed` | `ALTER tasks ADD COLUMN auto_completed`; `idx_tasks_done_at_auto_completed`. Cascade-completed parents are tagged so the daily-completion query can exclude them. |
| 14 | transcripts: `llm_tags` column | `ALTER transcripts ADD COLUMN llm_tags TEXT NOT NULL DEFAULT ''`. Phase 9 LLM content tags. |
| 15 | composite (profile_id, created_at DESC) index | `CREATE INDEX idx_transcripts_profile_created`. Speeds up the profile-scoped history page query that currently uses `idx_transcripts_profile_id` alone. |

## Tests

Migration tests live alongside the registry at `crates/storage/src/migrations.rs:606-1184`:

- `test_migrations_run_on_empty_db` — full v1→head replay.
- `test_migrations_idempotent` — running twice is a no-op.
- `migration_tasks_meta_adds_columns` — v3 ALTER goes through.
- `migration_implementation_rules_adds_rule_table` — v12 happy path.
- `migration_transcripts_meta_adds_columns` — v5 ALTER goes through.
- `migration_transcript_profile_provenance_adds_profile_id` — v8.
- `migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk` — the v9 table rebuild end-to-end.
- `test_parent_task_id_cascade_delete` — v3 cascade FK behaviour.
- `migration_v6_seeds_default_profile_on_fresh_db`.
- `migration_v6_copies_dictionary_rows_to_default_profile_terms`.
- `migration_v6_trigger_rejects_default_profile_delete`.
- `migration_v6_trigger_rejects_default_profile_rename`.
- `migration_v7_drops_dictionary_table`.
- `multi_statement_migration_rolls_back_on_failure` — the C3 atomicity guarantee.
- `migration_v13_adds_auto_completed_column`.
- `migration_v15_creates_profile_created_index` — the head migration.

## Watch-outs

- **Append-only.** Modifying an existing migration after release is a contract violation. Add a new one.
- **`split_statements` is hand-rolled.** Triggers contain `BEGIN ... END` with embedded semicolons. The splitter at `migrations.rs:483` is trigger-aware (it tracks `BEGIN` / `END` depth) but the parser is hand-rolled rather than a real SQL tokeniser. Adding a new control-flow keyword (eg `CASE WHEN ... END` outside a trigger) could trip it. Test additions are advised.
- **Migration v9 is the only table rebuild.** Rebuilds drop the FTS index and recreate it. If a future column needs FK adjustment, the v9 pattern is the template, but it is more risk than an `ALTER`.
- **No down migrations.** Recovery from a bad migration is restore-from-backup. The pre-commit pattern is to test on a fresh DB and against a copy of a real one.

## Existing in-repo docs

- [`docs/issues/c3-migrations-atomicity.md`](../../issues/c3-migrations-atomicity.md) — RB-02. Drove the per-migration transaction wrapper.
- [`docs/issues/c4-transcript-profile-fk.md`](../../issues/c4-transcript-profile-fk.md) — drove migration v9.

## See also

- [Storage overview](storage-overview.md)
- [FTS5 search](storage-fts5-search.md)
- [Transcripts CRUD](storage-crud-transcripts.md)
- [Tasks CRUD](storage-crud-tasks.md)
- [Profiles CRUD](storage-crud-profiles.md)
- [Settings, error log, feedback, implementation rules](storage-crud-settings-and-misc.md)
