Skip to main content

mj_controller/database/
schema.rs

1use super::*;
2use rusqlite::OpenFlags;
3
4const COMPATIBILITY_METADATA_VERSION: i64 = 30;
5
6pub(super) struct SchemaState {
7    pub(super) revision: i64,
8    minimum_compatible: Option<i64>,
9}
10
11impl SchemaState {
12    pub(super) fn ensure_supported(&self) -> Result<()> {
13        let reason = if self.revision < SCHEMA_VERSION {
14            StoreSchemaMismatchReason::NeedsMigration
15        } else if let Some(minimum_compatible) = self.minimum_compatible {
16            if minimum_compatible <= SCHEMA_VERSION {
17                return Ok(());
18            }
19            StoreSchemaMismatchReason::Incompatible { minimum_compatible }
20        } else {
21            StoreSchemaMismatchReason::InvalidCompatibilityMetadata
22        };
23        Err(StoreSchemaMismatch {
24            found: self.revision,
25            supported: SCHEMA_VERSION,
26            reason,
27        }
28        .into())
29    }
30}
31
32/// The revision, ledger, and compatibility floor must describe one snapshot.
33/// A missing floor is only legitimate before compatibility was introduced.
34pub(super) fn read_schema_state(connection: &Connection) -> Result<SchemaState> {
35    let snapshot = connection
36        .unchecked_transaction()
37        .context("start database compatibility snapshot")?;
38    let revision: i64 = snapshot
39        .query_row("PRAGMA user_version", [], |row| row.get(0))
40        .context("read database migration revision")?;
41    let minimum_compatible = if revision >= COMPATIBILITY_METADATA_VERSION {
42        let invalid = || StoreSchemaMismatch {
43            found: revision,
44            supported: SCHEMA_VERSION,
45            reason: StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
46        };
47        let (count, singleton, floor, recorded): (i64, Option<i64>, Option<i64>, Option<i64>) =
48            snapshot
49                .query_row(
50                    "SELECT count(*), min(singleton), min(minimum_compatible_version),
51                    (SELECT max(version) FROM schema_migrations)
52             FROM schema_compatibility",
53                    [],
54                    |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
55                )
56                .map_err(|error| {
57                    // Missing tables/columns and invalid field types are
58                    // structural. Busy, I/O, and interruption errors are not
59                    // evidence of an incompatible migration.
60                    let structural = match &error {
61                        rusqlite::Error::SqliteFailure(code, _) => {
62                            code.code == rusqlite::ErrorCode::Unknown
63                        }
64                        _ => true,
65                    };
66                    let error = anyhow::Error::new(error);
67                    if structural {
68                        error.context(invalid())
69                    } else {
70                        error.context("read database compatibility metadata")
71                    }
72                })?;
73        if count != 1
74            || singleton != Some(1)
75            || recorded != Some(revision)
76            || !floor
77                .is_some_and(|floor| (COMPATIBILITY_METADATA_VERSION..=revision).contains(&floor))
78        {
79            return Err(invalid().into());
80        }
81        floor
82    } else {
83        None
84    };
85    snapshot
86        .commit()
87        .context("finish database compatibility snapshot")?;
88    Ok(SchemaState {
89        revision,
90        minimum_compatible,
91    })
92}
93
94pub fn database_path() -> PathBuf {
95    data_dir().join("mj.sqlite3")
96}
97
98pub(super) fn open_writer(path: &Path) -> Result<Connection> {
99    if let Some(parent) = path.parent() {
100        fs::create_dir_all(parent)
101            .with_context(|| format!("create Mjolnir data directory {}", parent.display()))?;
102    }
103    let connection = Connection::open(path)
104        .with_context(|| format!("open Mjolnir database {}", path.display()))?;
105    connection.busy_timeout(Duration::from_secs(5))?;
106    connection.execute_batch(
107        "PRAGMA foreign_keys = ON;
108         PRAGMA journal_mode = WAL;
109         PRAGMA synchronous = FULL;",
110    )?;
111    verify_schema_once(path, &connection)?;
112    Ok(connection)
113}
114
115pub(super) fn open(path: &Path) -> Result<Connection> {
116    open_writer(path)
117}
118
119/// Open an existing database without permitting schema or data mutation.
120/// Client processes use this path so an accidental write fails locally
121/// instead of competing with the daemon's writer.
122#[cfg(not(test))]
123pub(super) fn open_reader(path: &Path) -> Result<Connection> {
124    open_reader_strict(path)
125}
126
127#[cfg(test)]
128pub(super) fn open_reader(path: &Path) -> Result<Connection> {
129    // Path-taking database helpers are migration fixtures in unit tests: they
130    // intentionally open old or not-yet-created schemas. Production query
131    // entry points compile against the strict reader above.
132    open_writer(path)
133}
134
135#[cfg_attr(test, allow(dead_code))]
136fn open_reader_strict(path: &Path) -> Result<Connection> {
137    let connection = Connection::open_with_flags(
138        path,
139        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
140    )
141    .with_context(|| format!("open Mjolnir database read-only {}", path.display()))?;
142    connection.busy_timeout(Duration::from_secs(5))?;
143    connection.execute_batch(
144        "PRAGMA foreign_keys = ON;
145         PRAGMA query_only = ON;",
146    )?;
147    read_schema_state(&connection)?.ensure_supported()?;
148    Ok(connection)
149}
150
151/// Databases this process has already migrated. A controller owns its store
152/// exclusively (`ControllerStoreGuard`), so a schema verified once stays
153/// verified and later connections skip the migration probes entirely.
154fn verified_schemas() -> &'static Mutex<HashSet<PathBuf>> {
155    static VERIFIED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
156    VERIFIED.get_or_init(|| Mutex::new(HashSet::new()))
157}
158
159/// Stable cache identity for a database. The file itself may not exist yet, so
160/// the canonicalized parent directory carries the identity.
161fn schema_cache_key(path: &Path) -> PathBuf {
162    let Some(parent) = path
163        .parent()
164        .filter(|parent| !parent.as_os_str().is_empty())
165    else {
166        return path.to_owned();
167    };
168    match (fs::canonicalize(parent), path.file_name()) {
169        (Ok(canonical), Some(name)) => canonical.join(name),
170        _ => path.to_owned(),
171    }
172}
173
174/// Run the migration ladder the first time this process opens a database.
175/// Later opens confirm compatibility without repeating schema repairs. A
176/// database behind this build is migrated again, so a recreated file under a
177/// reused path still converges. Compatible future stores are never repaired.
178fn verify_schema_once(path: &Path, connection: &Connection) -> Result<()> {
179    let key = schema_cache_key(path);
180    let mut verified = verified_schemas()
181        .lock()
182        .unwrap_or_else(PoisonError::into_inner);
183    let state = read_schema_state(connection)?;
184    if state.revision > SCHEMA_VERSION
185        || (state.revision == SCHEMA_VERSION && verified.contains(&key))
186    {
187        // An older build must never run its repairs against a newer schema.
188        return state.ensure_supported();
189    }
190    // Holding the lock across the ladder keeps two first opens of the same
191    // database from running the additive migration steps against each other.
192    migrate_schema(connection)?;
193    read_schema_state(connection)?.ensure_supported()?;
194    verified.insert(key);
195    Ok(())
196}
197
198/// Forget that this process verified a database's schema. Only tests need it:
199/// they simulate a store written by an older build by editing the schema of a
200/// database this process has already opened, which no controller can do.
201#[cfg(test)]
202pub(super) fn forget_verified_schema(path: &Path) {
203    verified_schemas()
204        .lock()
205        .unwrap_or_else(PoisonError::into_inner)
206        .remove(&schema_cache_key(path));
207}
208
209fn migrate_schema(connection: &Connection) -> Result<()> {
210    let state = read_schema_state(connection)?;
211    let version = state.revision;
212    if version > SCHEMA_VERSION {
213        return state.ensure_supported();
214    }
215    if version == 0 {
216        connection.execute_batch(
217            "BEGIN IMMEDIATE;
218             CREATE TABLE schema_migrations (
219                 version INTEGER PRIMARY KEY CHECK(version > 0),
220                 applied_at TEXT NOT NULL
221             ) STRICT;
222             CREATE TABLE session_contexts (
223                 session_id TEXT PRIMARY KEY,
224                 bundle_id TEXT NOT NULL,
225                 created_at TEXT NOT NULL
226             ) STRICT;
227             CREATE TABLE sessions (
228                 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
229                 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
230                 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
231                 last_profile TEXT NOT NULL,
232                 target_template_id TEXT NOT NULL,
233                 state TEXT NOT NULL CHECK(state IN (
234                     'provisioning','running','disconnected','checkpointing','closing','destroying',
235                     'archived','lost','error','destroyed-with-data-loss'
236                 )),
237                 native_session_id TEXT,
238                 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
239                 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
240                 updated_at TEXT NOT NULL,
241                 last_viewed_event_sequence INTEGER NOT NULL DEFAULT 0 CHECK(last_viewed_event_sequence >= 0),
242                 last_error TEXT
243             ) STRICT;
244             CREATE TABLE session_targets (
245                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
246                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','apple-container','aws-ec2','ssh-bare','ssh-podman')),
247                 host TEXT,
248                 resource_id TEXT,
249                 address TEXT,
250                 workspace BLOB,
251                 worker_id TEXT,
252                 CHECK(
253                     (kind = 'local-bare' AND workspace IS NOT NULL
254                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
255                  OR (kind IN ('local-podman','apple-container') AND resource_id IS NOT NULL
256                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
257                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
258                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
259                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
260                      AND resource_id IS NULL AND address IS NULL)
261                  OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
262                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
263                 )
264             ) STRICT;
265             CREATE TABLE session_mounts (
266                 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
267                 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
268                 source BLOB NOT NULL,
269                 destination BLOB NOT NULL,
270                 PRIMARY KEY(session_id, ordinal),
271                 UNIQUE(session_id, destination)
272             ) STRICT;
273             CREATE TABLE session_checkpoints (
274                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
275                 archive_path BLOB NOT NULL,
276                 sha256 TEXT NOT NULL CHECK(length(sha256) = 64 AND sha256 NOT GLOB '*[^0-9a-f]*'),
277                 created_at TEXT NOT NULL,
278                 event_sequence INTEGER NOT NULL CHECK(event_sequence >= 0)
279             ) STRICT;
280             CREATE TABLE mount_history (
281                 host TEXT NOT NULL CHECK(length(trim(host)) > 0),
282                 source BLOB NOT NULL,
283                 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
284                 PRIMARY KEY(host, ordinal),
285                 UNIQUE(host, source)
286             ) STRICT;
287             CREATE TABLE prompt_history (
288                 history_id INTEGER PRIMARY KEY,
289                 session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
290                 event_sequence INTEGER NOT NULL CHECK(event_sequence >= 0),
291                 submitted_at TEXT NOT NULL,
292                 text TEXT NOT NULL CHECK(length(trim(text)) > 0),
293                 UNIQUE(session_id, event_sequence)
294             ) STRICT;
295             CREATE INDEX prompt_history_session_recent
296                 ON prompt_history(session_id, history_id DESC);
297             CREATE INDEX session_contexts_bundle
298                 ON session_contexts(bundle_id, session_id);
299             CREATE INDEX prompt_history_recent
300                 ON prompt_history(history_id DESC);
301             INSERT INTO schema_migrations(version, applied_at)
302                 VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
303             PRAGMA user_version = 1;
304             COMMIT;",
305        )?;
306    }
307    if version < 2 {
308        connection.execute_batch(
309            "BEGIN IMMEDIATE;
310             ALTER TABLE sessions ADD COLUMN resource_allocation TEXT;
311             INSERT INTO schema_migrations(version, applied_at)
312                 VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
313             PRAGMA user_version = 2;
314             COMMIT;",
315        )?;
316    }
317    if version < 3 {
318        connection.execute_batch(
319            "BEGIN IMMEDIATE;
320             ALTER TABLE sessions ADD COLUMN last_checkpoint_error TEXT;
321             INSERT INTO schema_migrations(version, applied_at)
322                 VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
323             PRAGMA user_version = 3;
324             COMMIT;",
325        )?;
326    }
327    if version < 4 {
328        connection.execute_batch(
329            "BEGIN IMMEDIATE;
330             ALTER TABLE sessions ADD COLUMN project_directory BLOB;
331             INSERT INTO schema_migrations(version, applied_at)
332                 VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
333             PRAGMA user_version = 4;
334             COMMIT;",
335        )?;
336    }
337    if version < 5 {
338        connection.execute_batch(
339            "BEGIN IMMEDIATE;
340             ALTER TABLE session_targets RENAME TO session_targets_v4;
341             CREATE TABLE session_targets (
342                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
343                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','apple-container','aws-ec2','ssh-bare','ssh-podman')),
344                 host TEXT,
345                 resource_id TEXT,
346                 address TEXT,
347                 workspace BLOB,
348                 worker_id TEXT,
349                 CHECK(
350                     (kind = 'local-bare' AND workspace IS NOT NULL
351                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
352                  OR (kind IN ('local-podman','apple-container') AND resource_id IS NOT NULL
353                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
354                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
355                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
356                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
357                      AND resource_id IS NULL AND address IS NULL)
358                  OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
359                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
360                 )
361             ) STRICT;
362             INSERT INTO session_targets
363                 SELECT * FROM session_targets_v4;
364             DROP TABLE session_targets_v4;
365             INSERT INTO schema_migrations(version, applied_at)
366                 VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
367             PRAGMA user_version = 5;
368             COMMIT;",
369        )?;
370    }
371    if version < 6 {
372        connection.execute_batch(&format!(
373            "BEGIN IMMEDIATE;
374             ALTER TABLE session_checkpoints
375                 RENAME COLUMN event_sequence TO event_frontier;
376             ALTER TABLE prompt_history
377                 RENAME COLUMN event_sequence TO event_ordinal;
378             ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
379                 DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
380             ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
381             CREATE TABLE materialized_sessions (
382                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
383                 applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
384                 applied_event_digest TEXT NOT NULL
385                     DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
386                     CHECK(length(applied_event_digest) = 64
387                           AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
388                 last_activity_at_ms INTEGER,
389                 execution_state TEXT NOT NULL DEFAULT 'idle'
390                     CHECK(execution_state IN ('idle','running','closing','closed')),
391                 running_started_at_ms INTEGER,
392                 session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
393                 configuration_json TEXT NOT NULL DEFAULT '{{}}',
394                 CHECK(
395                     (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
396                     OR (execution_state != 'running' AND running_started_at_ms IS NULL)
397                 )
398             ) STRICT;
399             CREATE TABLE materialized_transcript_items (
400                 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
401                 stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
402                 position INTEGER NOT NULL CHECK(position > 0),
403                 latest_content_event_ordinal INTEGER
404                     CHECK(latest_content_event_ordinal IS NULL
405                           OR latest_content_event_ordinal >= position),
406                 created_at_ms INTEGER NOT NULL,
407                 last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
408                 body_json TEXT NOT NULL,
409                 PRIMARY KEY(session_id, stable_id)
410             ) STRICT;
411             CREATE INDEX materialized_transcript_position
412                 ON materialized_transcript_items(session_id, position, stable_id);
413             CREATE TABLE materialized_queued_prompts (
414                 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
415                 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
416                 command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
417                 content_json TEXT NOT NULL,
418                 queued_at_ms INTEGER NOT NULL,
419                 PRIMARY KEY(session_id, ordinal),
420                 UNIQUE(session_id, command_id)
421             ) STRICT;
422             INSERT INTO materialized_sessions(session_id)
423                 SELECT session_id FROM sessions;
424             INSERT INTO schema_migrations(version, applied_at)
425                 VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
426             PRAGMA user_version = 6;
427             COMMIT;",
428        ))?;
429    }
430    // Both development lines used schema version 6: durable relay projection
431    // on this branch and managed raw-session worktrees on master. Structural
432    // guards make either already-written v6 database converge before the v7
433    // sessions-table rebuild, without inventing a second version-6 ledger row.
434    ensure_managed_worktree_column(connection)?;
435    if version < 7 {
436        ensure_relay_projection_schema(connection)?;
437        migrate_destroying_session_state(connection)?;
438    }
439    ensure_projection_digest_column(connection)?;
440    ensure_session_draft_input_column(connection)?;
441    if version < 8 {
442        // Queue entries gained a kind so a configuration change can wait in the
443        // same queue as prompts. Rows written before that are prompts.
444        connection.execute_batch(
445            "BEGIN IMMEDIATE;
446             ALTER TABLE materialized_queued_prompts
447                 ADD COLUMN kind_json TEXT NOT NULL DEFAULT '\"prompt\"';
448             INSERT INTO schema_migrations(version, applied_at)
449                 VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
450             PRAGMA user_version = 8;
451             COMMIT;",
452        )?;
453    }
454    // Runs last: it rebuilds `sessions`, so every column the steps above add
455    // must already exist to be copied forward.
456    if version < 9 {
457        migrate_grok_harness_kind(connection)?;
458    }
459    // Added after the v9 rebuild so the rebuild never has to copy them.
460    ensure_session_container_override_columns(connection)?;
461    ensure_session_mount_read_only_column(connection)?;
462    ensure_materialized_elicitation_column(connection)?;
463    connection.execute_batch(
464        "CREATE TABLE IF NOT EXISTS profile_config_cache (
465        profile TEXT NOT NULL, model TEXT NOT NULL, fingerprint TEXT NOT NULL,
466        observed_at INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(profile, model));
467        CREATE TABLE IF NOT EXISTS api_config_results (
468        session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
469        command_id TEXT NOT NULL, error TEXT, PRIMARY KEY(session_id, command_id));
470        CREATE TABLE IF NOT EXISTS session_turn_usage (
471        session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
472        command_id TEXT NOT NULL, completed_ordinal INTEGER NOT NULL, turn_start_position INTEGER,
473        body TEXT NOT NULL, PRIMARY KEY(session_id, command_id));
474        CREATE INDEX IF NOT EXISTS session_turn_usage_order ON session_turn_usage(session_id, completed_ordinal);
475        CREATE TABLE IF NOT EXISTS session_provider_cost (
476        session_id TEXT PRIMARY KEY REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
477        body TEXT NOT NULL);",
478    )?;
479
480    if version < 10 {
481        migrate_stopped_session_state(connection)?;
482    }
483    if version < 11 {
484        migrate_deepseek_harness_kind(connection)?;
485    }
486    if version < 12 {
487        connection.execute_batch(
488            "BEGIN IMMEDIATE;
489             ALTER TABLE sessions
490                 RENAME COLUMN detached_after_event_ordinal TO viewed_through_event_ordinal;
491             UPDATE sessions
492                 SET target_template_id = 'localhost'
493                 WHERE target_template_id = 'raw-localhost';
494             INSERT INTO schema_migrations(version, applied_at)
495                 VALUES (12, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
496             PRAGMA user_version = 12;
497             COMMIT;",
498        )?;
499    }
500    if version < 13 {
501        connection.execute_batch(
502            "BEGIN IMMEDIATE;
503             UPDATE sessions
504                SET state = 'error'
505              WHERE state = 'lost'
506                AND EXISTS(
507                    SELECT 1
508                      FROM session_checkpoints
509                     WHERE session_checkpoints.session_id = sessions.session_id
510                );
511             INSERT INTO schema_migrations(version, applied_at)
512                 VALUES (13, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
513             PRAGMA user_version = 13;
514             COMMIT;",
515        )?;
516    }
517    if version < 14 {
518        ensure_workspace_schema(connection)?;
519        connection.execute_batch(
520            "BEGIN IMMEDIATE;
521             INSERT OR IGNORE INTO schema_migrations(version, applied_at)
522                 VALUES (14, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
523             PRAGMA user_version = 14;
524             COMMIT;",
525        )?;
526    }
527    if version < 15 {
528        connection.execute_batch(
529            "BEGIN IMMEDIATE;
530             CREATE TABLE host_container_sizes (
531                 host TEXT PRIMARY KEY CHECK(length(trim(host)) > 0),
532                 cpus INTEGER NOT NULL CHECK(cpus > 0),
533                 memory_bytes INTEGER NOT NULL CHECK(memory_bytes > 0)
534             ) STRICT;
535             INSERT INTO schema_migrations(version, applied_at)
536                 VALUES (15, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
537             PRAGMA user_version = 15;
538             COMMIT;",
539        )?;
540    }
541    if version < 16 {
542        connection.execute_batch(
543            "BEGIN IMMEDIATE;
544             CREATE TABLE second_opinion_defaults (
545                 workspace_id TEXT NOT NULL CHECK(length(trim(workspace_id)) > 0),
546                 profile_id TEXT NOT NULL CHECK(length(trim(profile_id)) > 0),
547                 model TEXT NOT NULL,
548                 effort TEXT NOT NULL,
549                 PRIMARY KEY (workspace_id, profile_id, model)
550             ) STRICT;
551             INSERT INTO schema_migrations(version, applied_at)
552                 VALUES (16, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
553             PRAGMA user_version = 16;
554             COMMIT;",
555        )?;
556    }
557    if version < 17 {
558        connection.execute_batch(
559            "BEGIN IMMEDIATE;
560             CREATE TABLE second_opinion_reviews (
561                 session_id TEXT PRIMARY KEY
562                     REFERENCES sessions(session_id) ON DELETE CASCADE,
563                 workflow TEXT NOT NULL,
564                 generation INTEGER NOT NULL CHECK(generation >= 0),
565                 context_baseline INTEGER NOT NULL CHECK(context_baseline >= 0),
566                 native_lost INTEGER NOT NULL CHECK(native_lost IN (0, 1))
567             ) STRICT;
568             INSERT INTO schema_migrations(version, applied_at)
569                 VALUES (17, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
570             PRAGMA user_version = 17;
571             COMMIT;",
572        )?;
573    }
574    if version < 18 {
575        // The reviewer's conversation lives in its own journal on the target.
576        // Losing the target takes that journal with it, so the controller
577        // keeps a copy of what it has already read: the conversation stays
578        // readable for reference even though it can no longer be continued.
579        connection.execute_batch(
580            "BEGIN IMMEDIATE;
581             ALTER TABLE second_opinion_reviews
582                 ADD COLUMN reviewer_transcript TEXT NOT NULL DEFAULT '[]';
583             INSERT INTO schema_migrations(version, applied_at)
584                 VALUES (18, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
585             PRAGMA user_version = 18;
586             COMMIT;",
587        )?;
588    }
589    if version < 19 {
590        // Turn review is per workspace (is it on, and at which tier) and per
591        // session (what has already been reviewed). Neither belongs on
592        // `SessionRecord`, which is a compatibility surface with nine
593        // construction sites; `second_opinion_reviews` above is the precedent
594        // for keeping review state in its own table.
595        connection.execute_batch(
596            "BEGIN IMMEDIATE;
597             CREATE TABLE turn_review_settings (
598                 workspace_id TEXT PRIMARY KEY
599                     CHECK(length(trim(workspace_id)) > 0),
600                 auto_review INTEGER NOT NULL CHECK(auto_review IN (0, 1)),
601                 tier TEXT NOT NULL CHECK(tier IN ('quick', 'extended'))
602             ) STRICT;
603             CREATE TABLE turn_review_state (
604                 session_id TEXT PRIMARY KEY
605                     REFERENCES sessions(session_id) ON DELETE CASCADE,
606                 baselines TEXT NOT NULL,
607                 reviewed_through_ordinal INTEGER NOT NULL
608                     CHECK(reviewed_through_ordinal >= 0),
609                 prior_review TEXT,
610                 active TEXT
611             ) STRICT;
612             INSERT INTO schema_migrations(version, applied_at)
613                 VALUES (19, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
614             PRAGMA user_version = 19;
615             COMMIT;",
616        )?;
617    }
618    if version < 20 {
619        let target_table_exists: bool = connection.query_row(
620            "SELECT EXISTS(
621                 SELECT 1 FROM sqlite_master
622                 WHERE type = 'table' AND name = 'session_targets'
623             )",
624            [],
625            |row| row.get(0),
626        )?;
627        let rebuild = if target_table_exists {
628            "ALTER TABLE session_targets RENAME TO session_targets_v19;"
629        } else {
630            ""
631        };
632        let copy = if target_table_exists {
633            "INSERT INTO session_targets SELECT * FROM session_targets_v19;
634             DROP TABLE session_targets_v19;"
635        } else {
636            ""
637        };
638        connection.execute_batch(&format!(
639            "BEGIN IMMEDIATE;
640             {rebuild}
641             CREATE TABLE session_targets (
642                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
643                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','local-docker','apple-container','aws-ec2','ssh-bare','ssh-podman')),
644                 host TEXT,
645                 resource_id TEXT,
646                 address TEXT,
647                 workspace BLOB,
648                 worker_id TEXT,
649                 CHECK(
650                     (kind = 'local-bare' AND workspace IS NOT NULL
651                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
652                  OR (kind IN ('local-podman','local-docker','apple-container') AND resource_id IS NOT NULL
653                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
654                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
655                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
656                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
657                      AND resource_id IS NULL AND address IS NULL)
658                  OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
659                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
660                 )
661             ) STRICT;
662             {copy}
663             INSERT INTO schema_migrations(version, applied_at)
664                 VALUES (20, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
665             PRAGMA user_version = 20;
666             COMMIT;"
667        ))?;
668    }
669    if version < 21 {
670        // Arming review moved into `[review]` in config.toml, which is where
671        // the rest of Mjolnir's durable global configuration lives and the only
672        // place a phone-only user could ever have set it. No data is
673        // migrated: a workspace-to-global mapping has no defensible merge
674        // rule, and the release note says to re-arm it in the config file.
675        connection.execute_batch(
676            "BEGIN IMMEDIATE;
677             DROP TABLE IF EXISTS turn_review_settings;
678             INSERT INTO schema_migrations(version, applied_at)
679                 VALUES (21, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
680             PRAGMA user_version = 21;
681             COMMIT;",
682        )?;
683    }
684    if version < 22 {
685        connection.execute_batch(
686            "BEGIN IMMEDIATE;
687             ALTER TABLE session_targets ADD COLUMN workspace_storage TEXT;
688             INSERT INTO schema_migrations(version, applied_at)
689                 VALUES (22, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
690             PRAGMA user_version = 22;
691             COMMIT;",
692        )?;
693    }
694    if version < 23 {
695        let add_pending_forward =
696            if table_has_column(connection, "turn_review_state", "pending_forward")? {
697                ""
698            } else {
699                "ALTER TABLE turn_review_state ADD COLUMN pending_forward TEXT;"
700            };
701        connection.execute_batch(&format!(
702            "BEGIN IMMEDIATE;
703             {add_pending_forward}
704             INSERT INTO schema_migrations(version, applied_at)
705                 VALUES (23, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
706             PRAGMA user_version = 23;
707             COMMIT;"
708        ))?;
709    }
710    if version < 24 {
711        connection.execute_batch(
712            "BEGIN IMMEDIATE;
713             ALTER TABLE session_targets RENAME TO session_targets_v23;
714             CREATE TABLE session_targets (
715                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
716                 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','local-docker','apple-container','aws-ec2','ssh-bare','ssh-podman','ssh-docker')),
717                 host TEXT,
718                 resource_id TEXT,
719                 address TEXT,
720                 workspace BLOB,
721                 worker_id TEXT,
722                 workspace_storage TEXT,
723                 CHECK(
724                     (kind = 'local-bare' AND workspace IS NOT NULL
725                      AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
726                  OR (kind IN ('local-podman','local-docker','apple-container') AND resource_id IS NOT NULL
727                      AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
728                  OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
729                      AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
730                  OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
731                      AND resource_id IS NULL AND address IS NULL)
732                  OR (kind IN ('ssh-podman','ssh-docker') AND host IS NOT NULL AND resource_id IS NOT NULL
733                      AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
734                 )
735             ) STRICT;
736             INSERT INTO session_targets SELECT * FROM session_targets_v23;
737             DROP TABLE session_targets_v23;
738             INSERT INTO schema_migrations(version, applied_at)
739                 VALUES (24, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
740             PRAGMA user_version = 24;
741             COMMIT;",
742        )?;
743    }
744    if version < 25 {
745        connection.execute_batch(
746            "BEGIN IMMEDIATE;
747             CREATE TABLE workspace_pane_sizes (
748                 workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
749                 sessions TEXT NOT NULL CHECK(sessions IN ('minimized', 'standard', 'maximized')),
750                 targets TEXT NOT NULL CHECK(targets IN ('minimized', 'standard', 'maximized')),
751                 quota TEXT NOT NULL CHECK(quota IN ('minimized', 'standard', 'maximized')),
752                 CHECK((sessions = 'maximized') + (targets = 'maximized') + (quota = 'maximized') <= 1)
753             ) STRICT;
754             INSERT INTO schema_migrations(version, applied_at)
755                 VALUES (25, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
756             PRAGMA user_version = 25;
757             COMMIT;",
758        )?;
759    }
760    if version < 26 {
761        connection.execute_batch(
762            "BEGIN IMMEDIATE;
763             CREATE TABLE session_moves (
764                 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
765                 operation_id TEXT NOT NULL UNIQUE,
766                 operation_json TEXT NOT NULL CHECK(json_valid(operation_json))
767             ) STRICT;
768             INSERT INTO schema_migrations(version, applied_at)
769                 VALUES (26, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
770             PRAGMA user_version = 26;
771             COMMIT;",
772        )?;
773    }
774    if version < 27 {
775        migrate_muse_harness_kind(connection)?;
776    }
777    // The projection gained per-turn identity and outcome so a caller driving a
778    // session through the HTTP API can wait for a specific prompt and read how
779    // it ended. `api_idempotency` makes session creation retry-safe.
780    if version < 28 {
781        migrate_turn_outcome_columns(connection)?;
782    }
783    if version < 29 {
784        connection.execute_batch(
785            "BEGIN IMMEDIATE;
786             ALTER TABLE sessions ADD COLUMN create_managed_worktree INTEGER
787                 CHECK(create_managed_worktree IN (0, 1));
788             INSERT INTO schema_migrations(version, applied_at)
789                 VALUES (29, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
790             PRAGMA user_version = 29;
791             COMMIT;",
792        )?;
793    }
794    if version < 30 {
795        migrate_compatibility_metadata(connection)?;
796    }
797    if version < 31 {
798        migrate_subagent_sessions(connection)?;
799    }
800    if version < 32 {
801        migrate_zcode_harness_kind(connection)?;
802    }
803    let recorded: Option<i64> =
804        connection.query_row("SELECT max(version) FROM schema_migrations", [], |row| {
805            row.get(0)
806        })?;
807    if recorded != Some(SCHEMA_VERSION) {
808        bail!(
809            "Mjolnir database migration ledger {:?} does not match schema {}",
810            recorded,
811            SCHEMA_VERSION
812        );
813    }
814    // A database that already applied migration 14 with a build older than the
815    // one that added `client_session_state` never got the table, since that
816    // migration only ran `ensure_workspace_schema` once, at version 14. Create
817    // it unconditionally (IF NOT EXISTS) on every writer open so an
818    // already-migrated database converges too.
819    ensure_client_session_state_schema(connection)?;
820    ensure_api_events_schema(connection)?;
821    Ok(())
822}
823
824/// Breaking baseline: earlier executables reject every newer revision.
825/// Later compatible migrations retain the floor; breaking ones raise it to
826/// their revision in the same transaction as their schema and ledger changes.
827fn migrate_compatibility_metadata(connection: &Connection) -> Result<()> {
828    let transaction = connection.unchecked_transaction()?;
829    transaction.execute_batch(
830        "CREATE TABLE schema_compatibility (
831             singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
832             minimum_compatible_version INTEGER NOT NULL CHECK(minimum_compatible_version >= 30)
833         ) STRICT;
834         INSERT INTO schema_compatibility(singleton, minimum_compatible_version) VALUES (1, 30);
835         INSERT INTO schema_migrations(version, applied_at)
836             VALUES (30, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
837         PRAGMA user_version = 30;",
838    )?;
839    transaction.commit()?;
840    Ok(())
841}
842
843/// Breaking migration: older controllers do not understand that a child
844/// borrows its parent's target and could destroy shared resources.
845fn migrate_subagent_sessions(connection: &Connection) -> Result<()> {
846    let transaction = connection.unchecked_transaction()?;
847    transaction.execute_batch(
848        "CREATE TABLE IF NOT EXISTS subagent_sessions (
849             child_session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
850             parent_session_id TEXT NOT NULL REFERENCES sessions(session_id),
851             request_key TEXT NOT NULL,
852             record_json TEXT NOT NULL CHECK(json_valid(record_json)),
853             CHECK(child_session_id <> parent_session_id),
854             UNIQUE(parent_session_id, request_key)
855         ) STRICT;
856         CREATE INDEX IF NOT EXISTS subagent_sessions_parent
857             ON subagent_sessions(parent_session_id, child_session_id);
858         UPDATE schema_compatibility SET minimum_compatible_version = 31
859             WHERE singleton = 1;
860         INSERT INTO schema_migrations(version, applied_at)
861             VALUES (31, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
862         PRAGMA user_version = 31;",
863    )?;
864    transaction.commit()?;
865    Ok(())
866}
867
868/// Breaking migration: older controllers cannot deserialize the new harness
869/// enum and their table constraints reject ZCode rows written by this build.
870fn migrate_zcode_harness_kind(connection: &Connection) -> Result<()> {
871    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
872    let migration = (|| -> Result<()> {
873        let transaction = connection.unchecked_transaction()?;
874        for table in ["sessions", "hidden_native_sessions"] {
875            let sql: String = transaction.query_row(
876                "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
877                [table],
878                |row| row.get(0),
879            )?;
880            let (_, definition) = sql
881                .split_once('(')
882                .context("missing harness table definition")?;
883            if definition.contains("'muse','zcode')") {
884                continue;
885            }
886            ensure!(
887                definition.contains("'muse')"),
888                "unexpected {table} harness constraint"
889            );
890            let definition = definition.replace("'muse')", "'muse','zcode')");
891            let objects: Vec<String> = transaction
892                .prepare(
893                    "SELECT sql FROM sqlite_schema WHERE tbl_name=?1
894                     AND type IN ('index','trigger') AND sql IS NOT NULL",
895                )?
896                .query_map([table], |row| row.get(0))?
897                .collect::<rusqlite::Result<_>>()?;
898            transaction.execute_batch(&format!(
899                "CREATE TABLE {table}_zcode_v32 ({definition};
900                 INSERT INTO {table}_zcode_v32 SELECT * FROM {table};
901                 DROP TABLE {table};
902                 ALTER TABLE {table}_zcode_v32 RENAME TO {table};"
903            ))?;
904            for object in objects {
905                transaction.execute_batch(&object)?;
906            }
907        }
908        ensure!(
909            !transaction
910                .prepare("PRAGMA foreign_key_check")?
911                .exists([])?,
912            "foreign key violation in ZCode migration"
913        );
914        transaction.execute_batch(
915            "UPDATE schema_compatibility SET minimum_compatible_version = 32
916                 WHERE singleton = 1;
917             INSERT INTO schema_migrations(version, applied_at)
918                 VALUES (32, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
919             PRAGMA user_version = 32;",
920        )?;
921        transaction.commit()?;
922        Ok(())
923    })();
924    let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
925    migration.context("migrate ZCode harness constraints")?;
926    restored.context("restore foreign key enforcement after ZCode migration")?;
927    Ok(())
928}
929
930pub(super) fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
931    connection
932        .query_row(
933            "SELECT EXISTS(
934                 SELECT 1 FROM pragma_table_info(?1)
935                 WHERE name = ?2
936             )",
937            params![table, column],
938            |row| row.get(0),
939        )
940        .map_err(Into::into)
941}
942
943fn ensure_workspace_schema(connection: &Connection) -> Result<()> {
944    connection.execute_batch(
945        "CREATE TABLE IF NOT EXISTS workspaces (
946             workspace_id TEXT PRIMARY KEY CHECK(length(trim(workspace_id)) > 0),
947             name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 64),
948             name_key TEXT NOT NULL UNIQUE CHECK(length(trim(name_key)) BETWEEN 1 AND 64),
949             created_at TEXT NOT NULL,
950             last_opened_at TEXT NOT NULL
951         ) STRICT;
952         INSERT OR IGNORE INTO workspaces(
953             workspace_id, name, name_key, created_at, last_opened_at
954         ) VALUES (
955             'default', 'default', 'default',
956             strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
957             strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
958         );",
959    )?;
960    if !table_has_column(connection, "session_contexts", "workspace_id")? {
961        connection.execute_batch(
962            "ALTER TABLE session_contexts
963                 ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default';",
964        )?;
965    }
966    connection.execute_batch(
967        "CREATE INDEX IF NOT EXISTS session_contexts_workspace
968             ON session_contexts(workspace_id, session_id);
969         CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_insert
970         BEFORE INSERT ON session_contexts
971         WHEN NOT EXISTS(
972             SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
973         )
974         BEGIN
975             SELECT RAISE(ABORT, 'unknown workspace');
976         END;
977         CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_update
978         BEFORE UPDATE OF workspace_id ON session_contexts
979         WHEN NOT EXISTS(
980             SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
981         )
982         BEGIN
983             SELECT RAISE(ABORT, 'unknown workspace');
984         END;
985         CREATE TABLE IF NOT EXISTS client_read_frontiers (
986             client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
987             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
988             session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
989             through_event_ordinal INTEGER NOT NULL DEFAULT 0
990                 CHECK(through_event_ordinal >= 0),
991             updated_at TEXT NOT NULL,
992             PRIMARY KEY(client_id, workspace_id, session_id)
993         ) STRICT;
994         CREATE TABLE IF NOT EXISTS detached_drafts (
995             draft_id TEXT PRIMARY KEY CHECK(length(trim(draft_id)) > 0),
996             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id),
997             session_id TEXT REFERENCES session_contexts(session_id),
998             source TEXT NOT NULL CHECK(length(trim(source)) > 0),
999             owner_pid INTEGER CHECK(owner_pid IS NULL OR owner_pid > 0),
1000             saved_at TEXT NOT NULL,
1001             text TEXT NOT NULL CHECK(length(text) > 0),
1002             recovered_at TEXT
1003         ) STRICT;
1004         CREATE INDEX IF NOT EXISTS detached_drafts_workspace_recent
1005             ON detached_drafts(workspace_id, saved_at DESC);",
1006    )?;
1007    ensure_client_session_state_schema(connection)?;
1008    Ok(())
1009}
1010
1011/// Per-viewer, per-session state a web client keeps between visits.
1012///
1013/// This is additive and separate from `client_read_frontiers` on purpose. A
1014/// frontier is a cursor every client has; a draft is text one viewer typed and
1015/// did not send, and it expires. Keeping them apart means the phone's
1016/// retention policy cannot reach a terminal client's cursor.
1017fn ensure_client_session_state_schema(connection: &Connection) -> Result<()> {
1018    connection.execute_batch(
1019        "CREATE TABLE IF NOT EXISTS client_session_state (
1020             client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
1021             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
1022             session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
1023             draft TEXT NOT NULL DEFAULT '',
1024             updated_at TEXT NOT NULL,
1025             PRIMARY KEY(client_id, workspace_id, session_id)
1026         ) STRICT;
1027         CREATE INDEX IF NOT EXISTS client_session_state_age
1028             ON client_session_state(updated_at);",
1029    )?;
1030    Ok(())
1031}
1032
1033/// Per-session container size overrides. They are additive columns, so
1034/// databases written before the dashboard could edit them open unchanged.
1035fn ensure_session_container_override_columns(connection: &Connection) -> Result<()> {
1036    for column in ["container_cpus", "container_memory"] {
1037        if !table_has_column(connection, "sessions", column)? {
1038            connection.execute_batch(&format!(
1039                "BEGIN IMMEDIATE;
1040                 ALTER TABLE sessions ADD COLUMN {column} TEXT;
1041                 COMMIT;"
1042            ))?;
1043        }
1044    }
1045    Ok(())
1046}
1047
1048/// Per-mount read-only flag. It is an additive column, so a database written
1049/// before the mount editors offered the option opens unchanged and its mounts
1050/// keep the copy-on-write overlay they were provisioned with.
1051/// Add the per-turn identity and outcome columns, the queue's acceptance
1052/// ordinal, and the API idempotency ledger.
1053///
1054/// Each addition is guarded by a structural check rather than by the version
1055/// alone. A database rebuilt by another build's ladder — or by a test that
1056/// rewinds `user_version` — can already carry some of these, and a bare
1057/// `ALTER TABLE` would then fail the whole open.
1058fn migrate_turn_outcome_columns(connection: &Connection) -> Result<()> {
1059    let mut statements = String::from("BEGIN IMMEDIATE;\n");
1060    if !table_has_column(connection, "materialized_sessions", "active_turn_json")? {
1061        statements.push_str(
1062            "ALTER TABLE materialized_sessions ADD COLUMN active_turn_json TEXT
1063                 CHECK(active_turn_json IS NULL OR json_valid(active_turn_json));\n",
1064        );
1065    }
1066    if !table_has_column(
1067        connection,
1068        "materialized_sessions",
1069        "last_turn_outcome_json",
1070    )? {
1071        statements.push_str(
1072            "ALTER TABLE materialized_sessions ADD COLUMN last_turn_outcome_json TEXT
1073                 CHECK(last_turn_outcome_json IS NULL OR json_valid(last_turn_outcome_json));\n",
1074        );
1075    }
1076    if !table_has_column(
1077        connection,
1078        "materialized_queued_prompts",
1079        "accepted_ordinal",
1080    )? {
1081        statements.push_str(
1082            "ALTER TABLE materialized_queued_prompts ADD COLUMN accepted_ordinal INTEGER
1083                 CHECK(accepted_ordinal IS NULL OR accepted_ordinal > 0);\n",
1084        );
1085    }
1086    statements.push_str(
1087        "CREATE TABLE IF NOT EXISTS api_idempotency (
1088             key TEXT PRIMARY KEY CHECK(length(trim(key)) BETWEEN 1 AND 128),
1089             session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1090             created_at_ms INTEGER NOT NULL
1091         ) STRICT;
1092         INSERT INTO schema_migrations(version, applied_at)
1093             VALUES (28, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1094         PRAGMA user_version = 28;
1095         COMMIT;",
1096    );
1097    connection.execute_batch(&statements)?;
1098    Ok(())
1099}
1100
1101fn ensure_session_mount_read_only_column(connection: &Connection) -> Result<()> {
1102    if !table_has_column(connection, "session_mounts", "read_only")? {
1103        connection.execute_batch(
1104            "BEGIN IMMEDIATE;
1105             ALTER TABLE session_mounts ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0;
1106             COMMIT;",
1107        )?;
1108    }
1109    Ok(())
1110}
1111
1112fn ensure_materialized_elicitation_column(connection: &Connection) -> Result<()> {
1113    if !table_has_column(
1114        connection,
1115        "materialized_sessions",
1116        "pending_elicitations_json",
1117    )? {
1118        connection.execute_batch(
1119            "BEGIN IMMEDIATE;
1120             ALTER TABLE materialized_sessions
1121                 ADD COLUMN pending_elicitations_json TEXT NOT NULL DEFAULT '[]';
1122             COMMIT;",
1123        )?;
1124    }
1125    Ok(())
1126}
1127
1128fn ensure_managed_worktree_column(connection: &Connection) -> Result<()> {
1129    if !table_has_column(connection, "sessions", "managed_worktree")? {
1130        connection.execute_batch(
1131            "BEGIN IMMEDIATE;
1132             ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
1133             COMMIT;",
1134        )?;
1135    }
1136    Ok(())
1137}
1138
1139/// Complete the relay half of the colliding v6 migration for databases first
1140/// opened by master, whose v6 contained only `managed_worktree`.
1141fn ensure_relay_projection_schema(connection: &Connection) -> Result<()> {
1142    if table_has_column(connection, "sessions", "detached_after_event_ordinal")? {
1143        return Ok(());
1144    }
1145    connection.execute_batch(&format!(
1146        "BEGIN IMMEDIATE;
1147         ALTER TABLE session_checkpoints
1148             RENAME COLUMN event_sequence TO event_frontier;
1149         ALTER TABLE prompt_history
1150             RENAME COLUMN event_sequence TO event_ordinal;
1151         ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
1152             DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
1153         CREATE TABLE materialized_sessions (
1154             session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1155             applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
1156             applied_event_digest TEXT NOT NULL
1157                 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1158                 CHECK(length(applied_event_digest) = 64
1159                       AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
1160             last_activity_at_ms INTEGER,
1161             execution_state TEXT NOT NULL DEFAULT 'idle'
1162                 CHECK(execution_state IN ('idle','running','closing','closed')),
1163             running_started_at_ms INTEGER,
1164             session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
1165             configuration_json TEXT NOT NULL DEFAULT '{{}}',
1166             CHECK(
1167                 (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
1168                 OR (execution_state != 'running' AND running_started_at_ms IS NULL)
1169             )
1170         ) STRICT;
1171         CREATE TABLE materialized_transcript_items (
1172             session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1173             stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
1174             position INTEGER NOT NULL CHECK(position > 0),
1175             latest_content_event_ordinal INTEGER
1176                 CHECK(latest_content_event_ordinal IS NULL
1177                       OR latest_content_event_ordinal >= position),
1178             created_at_ms INTEGER NOT NULL,
1179             last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
1180             body_json TEXT NOT NULL,
1181             PRIMARY KEY(session_id, stable_id)
1182         ) STRICT;
1183         CREATE INDEX materialized_transcript_position
1184             ON materialized_transcript_items(session_id, position, stable_id);
1185         CREATE TABLE materialized_queued_prompts (
1186             session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1187             ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
1188             command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
1189             content_json TEXT NOT NULL,
1190             queued_at_ms INTEGER NOT NULL,
1191             PRIMARY KEY(session_id, ordinal),
1192             UNIQUE(session_id, command_id)
1193         ) STRICT;
1194         INSERT INTO materialized_sessions(session_id)
1195             SELECT session_id FROM sessions;
1196         COMMIT;",
1197    ))?;
1198    Ok(())
1199}
1200
1201fn migrate_destroying_session_state(connection: &Connection) -> Result<()> {
1202    // SQLite cannot widen a CHECK constraint in place. Foreign keys are
1203    // disabled only around the standard table-rebuild transaction; every
1204    // child continues to reference the replacement table by the same name.
1205    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1206    let migration = connection.execute_batch(
1207        "BEGIN IMMEDIATE;
1208         CREATE TABLE sessions_v7 (
1209             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1210             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1211             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
1212             last_profile TEXT NOT NULL,
1213             target_template_id TEXT NOT NULL,
1214             state TEXT NOT NULL CHECK(state IN (
1215                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1216                 'archived','lost','error','destroyed-with-data-loss'
1217             )),
1218             native_session_id TEXT,
1219             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1220             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1221             updated_at TEXT NOT NULL,
1222             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1223                 CHECK(detached_after_event_ordinal >= 0),
1224             last_error TEXT,
1225             resource_allocation TEXT,
1226             last_checkpoint_error TEXT,
1227             project_directory BLOB,
1228             managed_worktree TEXT
1229         ) STRICT;
1230         INSERT INTO sessions_v7(
1231             session_id, title, harness_kind, last_profile, target_template_id, state,
1232             native_session_id, acp_session_title, session_title_override, updated_at,
1233             detached_after_event_ordinal, last_error, resource_allocation,
1234             last_checkpoint_error, project_directory, managed_worktree
1235         )
1236         SELECT
1237             session_id, title, harness_kind, last_profile, target_template_id, state,
1238             native_session_id, acp_session_title, session_title_override, updated_at,
1239             detached_after_event_ordinal, last_error, resource_allocation,
1240             last_checkpoint_error, project_directory, managed_worktree
1241         FROM sessions;
1242         DROP TABLE sessions;
1243         ALTER TABLE sessions_v7 RENAME TO sessions;
1244         INSERT INTO schema_migrations(version, applied_at)
1245             VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1246         PRAGMA user_version = 7;
1247         COMMIT;",
1248    );
1249    if migration.is_err()
1250        && let Err(error) = connection.execute_batch("ROLLBACK;")
1251    {
1252        tracing::warn!(%error, "could not roll back durable-destroying-session migration");
1253    }
1254    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1255    migration.context("migrate durable destroying session state")?;
1256    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1257    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1258    if statement.exists([])? {
1259        bail!("foreign key violation after migrating durable destroying session state");
1260    }
1261    Ok(())
1262}
1263
1264/// Admit the Grok Build harness. SQLite cannot widen a CHECK constraint in
1265/// place, so this repeats the v7 table rebuild with the wider harness list.
1266/// Foreign keys are disabled only around the rebuild transaction; every child
1267/// continues to reference the replacement table by the same name.
1268fn migrate_grok_harness_kind(connection: &Connection) -> Result<()> {
1269    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1270    let migration = connection.execute_batch(
1271        "BEGIN IMMEDIATE;
1272         CREATE TABLE sessions_v9 (
1273             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1274             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1275             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1276             last_profile TEXT NOT NULL,
1277             target_template_id TEXT NOT NULL,
1278             state TEXT NOT NULL CHECK(state IN (
1279                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1280                 'archived','lost','error','destroyed-with-data-loss'
1281             )),
1282             native_session_id TEXT,
1283             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1284             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1285             updated_at TEXT NOT NULL,
1286             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1287                 CHECK(detached_after_event_ordinal >= 0),
1288             last_error TEXT,
1289             resource_allocation TEXT,
1290             last_checkpoint_error TEXT,
1291             project_directory BLOB,
1292             managed_worktree TEXT,
1293             draft_input TEXT NOT NULL DEFAULT ''
1294         ) STRICT;
1295         INSERT INTO sessions_v9(
1296             session_id, title, harness_kind, last_profile, target_template_id, state,
1297             native_session_id, acp_session_title, session_title_override, updated_at,
1298             detached_after_event_ordinal, last_error, resource_allocation,
1299             last_checkpoint_error, project_directory, managed_worktree, draft_input
1300         )
1301         SELECT
1302             session_id, title, harness_kind, last_profile, target_template_id, state,
1303             native_session_id, acp_session_title, session_title_override, updated_at,
1304             detached_after_event_ordinal, last_error, resource_allocation,
1305             last_checkpoint_error, project_directory, managed_worktree, draft_input
1306         FROM sessions;
1307         DROP TABLE sessions;
1308         ALTER TABLE sessions_v9 RENAME TO sessions;
1309         INSERT INTO schema_migrations(version, applied_at)
1310             VALUES (9, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1311         PRAGMA user_version = 9;
1312         COMMIT;",
1313    );
1314    if migration.is_err()
1315        && let Err(error) = connection.execute_batch("ROLLBACK;")
1316    {
1317        tracing::warn!(%error, "could not roll back Grok harness migration");
1318    }
1319    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1320    migration.context("migrate sessions table for the Grok Build harness")?;
1321    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1322    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1323    if statement.exists([])? {
1324        bail!("foreign key violation after migrating the sessions harness list");
1325    }
1326    Ok(())
1327}
1328
1329/// Rename the `archived` lifecycle state to `stopped` and give sessions their
1330/// own display-only `archived` flag, which now means "hidden from the resume
1331/// dialog". SQLite cannot narrow or widen a CHECK constraint in place, so this
1332/// repeats the v9 table rebuild with the new state list and the new column.
1333/// It also adds the hidden set for native sessions Mjolnir only reads.
1334fn migrate_stopped_session_state(connection: &Connection) -> Result<()> {
1335    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1336    let migration = connection.execute_batch(
1337        "BEGIN IMMEDIATE;
1338         CREATE TABLE sessions_v10 (
1339             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1340             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1341             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1342             last_profile TEXT NOT NULL,
1343             target_template_id TEXT NOT NULL,
1344             state TEXT NOT NULL CHECK(state IN (
1345                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1346                 'stopped','lost','error','destroyed-with-data-loss'
1347             )),
1348             native_session_id TEXT,
1349             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1350             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1351             updated_at TEXT NOT NULL,
1352             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1353                 CHECK(detached_after_event_ordinal >= 0),
1354             last_error TEXT,
1355             resource_allocation TEXT,
1356             last_checkpoint_error TEXT,
1357             project_directory BLOB,
1358             managed_worktree TEXT,
1359             draft_input TEXT NOT NULL DEFAULT '',
1360             container_cpus TEXT,
1361             container_memory TEXT,
1362             archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1363         ) STRICT;
1364         INSERT INTO sessions_v10(
1365             session_id, title, harness_kind, last_profile, target_template_id, state,
1366             native_session_id, acp_session_title, session_title_override, updated_at,
1367             detached_after_event_ordinal, last_error, resource_allocation,
1368             last_checkpoint_error, project_directory, managed_worktree, draft_input,
1369             container_cpus, container_memory
1370         )
1371         SELECT
1372             session_id, title, harness_kind, last_profile, target_template_id,
1373             CASE state WHEN 'archived' THEN 'stopped' ELSE state END,
1374             native_session_id, acp_session_title, session_title_override, updated_at,
1375             detached_after_event_ordinal, last_error, resource_allocation,
1376             last_checkpoint_error, project_directory, managed_worktree, draft_input,
1377             container_cpus, container_memory
1378         FROM sessions;
1379         DROP TABLE sessions;
1380         ALTER TABLE sessions_v10 RENAME TO sessions;
1381         CREATE TABLE hidden_native_sessions (
1382             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1383             native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1384             hidden_at TEXT NOT NULL,
1385             PRIMARY KEY(harness_kind, native_session_id)
1386         ) STRICT;
1387         INSERT INTO schema_migrations(version, applied_at)
1388             VALUES (10, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1389         PRAGMA user_version = 10;
1390         COMMIT;",
1391    );
1392    if migration.is_err()
1393        && let Err(error) = connection.execute_batch("ROLLBACK;")
1394    {
1395        tracing::warn!(%error, "could not roll back stopped-session migration");
1396    }
1397    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1398    migration.context("migrate sessions table for the stopped session state")?;
1399    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1400    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1401    if statement.exists([])? {
1402        bail!("foreign key violation after migrating the stopped session state");
1403    }
1404    Ok(())
1405}
1406
1407/// Preserve existing data and dependent indexes while admitting Muse sessions.
1408fn migrate_muse_harness_kind(connection: &Connection) -> Result<()> {
1409    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1410    let migration = (|| -> Result<()> {
1411        let transaction = connection.unchecked_transaction()?;
1412        for table in ["sessions", "hidden_native_sessions"] {
1413            let sql: String = transaction.query_row(
1414                "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
1415                [table],
1416                |row| row.get(0),
1417            )?;
1418            let (_, definition) = sql
1419                .split_once('(')
1420                .context("missing harness table definition")?;
1421            if definition.contains("'deepseek','muse')") {
1422                continue;
1423            }
1424            ensure!(
1425                definition.contains("'deepseek')"),
1426                "unexpected {table} harness constraint"
1427            );
1428            let definition = definition.replace("'deepseek')", "'deepseek','muse')");
1429            let objects: Vec<String> = transaction.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name=?1 AND type IN ('index','trigger') AND sql IS NOT NULL")?
1430                .query_map([table], |row| row.get(0))?.collect::<rusqlite::Result<_>>()?;
1431            transaction.execute_batch(&format!(
1432                "CREATE TABLE {table}_muse_v27 ({definition}; INSERT INTO {table}_muse_v27 SELECT * FROM {table}; DROP TABLE {table}; ALTER TABLE {table}_muse_v27 RENAME TO {table};"
1433            ))?;
1434            for object in objects {
1435                transaction.execute_batch(&object)?;
1436            }
1437        }
1438        ensure!(
1439            !transaction
1440                .prepare("PRAGMA foreign_key_check")?
1441                .exists([])?,
1442            "foreign key violation in Muse migration"
1443        );
1444        transaction.execute_batch("INSERT INTO schema_migrations(version, applied_at) VALUES (27, strftime('%Y-%m-%dT%H:%M:%fZ','now')); PRAGMA user_version = 27;")?;
1445        transaction.commit()?;
1446        Ok(())
1447    })();
1448    let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1449    migration.context("migrate Muse harness constraints")?;
1450    restored.context("restore foreign key enforcement after Muse migration")?;
1451    Ok(())
1452}
1453
1454/// Admit DeepSeek Harness in both stored sessions and Mjolnir's native-session
1455/// hidden set. SQLite requires rebuilding tables to widen CHECK constraints.
1456fn migrate_deepseek_harness_kind(connection: &Connection) -> Result<()> {
1457    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1458    let migration = connection.execute_batch(
1459        "BEGIN IMMEDIATE;
1460         CREATE TABLE sessions_v11 (
1461             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1462             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1463             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1464             last_profile TEXT NOT NULL,
1465             target_template_id TEXT NOT NULL,
1466             state TEXT NOT NULL CHECK(state IN (
1467                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1468                 'stopped','lost','error','destroyed-with-data-loss'
1469             )),
1470             native_session_id TEXT,
1471             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1472             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1473             updated_at TEXT NOT NULL,
1474             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1475                 CHECK(detached_after_event_ordinal >= 0),
1476             last_error TEXT,
1477             resource_allocation TEXT,
1478             last_checkpoint_error TEXT,
1479             project_directory BLOB,
1480             managed_worktree TEXT,
1481             draft_input TEXT NOT NULL DEFAULT '',
1482             container_cpus TEXT,
1483             container_memory TEXT,
1484             archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1485         ) STRICT;
1486         INSERT INTO sessions_v11 SELECT * FROM sessions;
1487         DROP TABLE sessions;
1488         ALTER TABLE sessions_v11 RENAME TO sessions;
1489         ALTER TABLE hidden_native_sessions RENAME TO hidden_native_sessions_v10;
1490         CREATE TABLE hidden_native_sessions (
1491             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1492             native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1493             hidden_at TEXT NOT NULL,
1494             PRIMARY KEY(harness_kind, native_session_id)
1495         ) STRICT;
1496         INSERT INTO hidden_native_sessions SELECT * FROM hidden_native_sessions_v10;
1497         DROP TABLE hidden_native_sessions_v10;
1498         INSERT INTO schema_migrations(version, applied_at)
1499             VALUES (11, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1500         PRAGMA user_version = 11;
1501         COMMIT;",
1502    );
1503    if migration.is_err()
1504        && let Err(error) = connection.execute_batch("ROLLBACK;")
1505    {
1506        tracing::warn!(%error, "could not roll back DeepSeek harness migration");
1507    }
1508    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1509    migration.context("migrate sessions table for DeepSeek Harness")?;
1510    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1511    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1512    if statement.exists([])? {
1513        bail!("foreign key violation after migrating the DeepSeek Harness list");
1514    }
1515    Ok(())
1516}
1517
1518/// Carry unsent chat input across a detach. Added as a structural guard rather
1519/// than a new schema version so databases written by either development line
1520/// converge, matching `ensure_managed_worktree_column`.
1521fn ensure_session_draft_input_column(connection: &Connection) -> Result<()> {
1522    if !table_has_column(connection, "sessions", "draft_input")? {
1523        connection.execute_batch(
1524            "BEGIN IMMEDIATE;
1525             ALTER TABLE sessions ADD COLUMN draft_input TEXT NOT NULL DEFAULT '';
1526             COMMIT;",
1527        )?;
1528    }
1529    Ok(())
1530}
1531
1532fn ensure_projection_digest_column(connection: &Connection) -> Result<()> {
1533    let present = connection.query_row(
1534        "SELECT EXISTS(
1535             SELECT 1 FROM pragma_table_info('materialized_sessions')
1536             WHERE name = 'applied_event_digest'
1537         )",
1538        [],
1539        |row| row.get::<_, bool>(0),
1540    )?;
1541    if !present {
1542        connection.execute_batch(&format!(
1543            "BEGIN IMMEDIATE;
1544             ALTER TABLE materialized_sessions ADD COLUMN applied_event_digest TEXT NOT NULL
1545                 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1546                 CHECK(length(applied_event_digest) = 64
1547                       AND applied_event_digest NOT GLOB '*[^0-9a-f]*');
1548             COMMIT;",
1549        ))?;
1550    }
1551    Ok(())
1552}
1553
1554fn ensure_api_events_schema(connection: &Connection) -> Result<()> {
1555    connection.execute_batch(
1556        "CREATE TABLE IF NOT EXISTS api_events (
1557            seq INTEGER PRIMARY KEY AUTOINCREMENT,
1558            session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1559            recorded_at_ms INTEGER NOT NULL,
1560            body TEXT NOT NULL CHECK(json_valid(body))
1561        ) STRICT;
1562        CREATE INDEX IF NOT EXISTS api_events_session ON api_events(session_id, seq);
1563        CREATE TABLE IF NOT EXISTS api_session_activity (
1564            session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1565            body TEXT NOT NULL CHECK(json_valid(body))
1566        ) STRICT;
1567        CREATE TRIGGER IF NOT EXISTS api_session_error_updated
1568        AFTER UPDATE OF last_error ON sessions
1569        WHEN NEW.last_error IS NOT NULL AND NEW.last_error IS NOT OLD.last_error
1570        BEGIN
1571            INSERT INTO api_events(session_id, recorded_at_ms, body)
1572            VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1573                json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1574        END;
1575        CREATE TRIGGER IF NOT EXISTS api_session_error_inserted
1576        AFTER INSERT ON sessions WHEN NEW.last_error IS NOT NULL
1577        BEGIN
1578            INSERT INTO api_events(session_id, recorded_at_ms, body)
1579            VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1580                json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1581        END;"
1582    )?;
1583    Ok(())
1584}
1585
1586#[cfg(test)]
1587pub(super) fn advance_test_schema(path: &Path, revision: i64, minimum_compatible: i64) {
1588    let connection = Connection::open(path).unwrap();
1589    let transaction = connection.unchecked_transaction().unwrap();
1590    transaction
1591        .execute(
1592            "UPDATE schema_compatibility SET minimum_compatible_version = ?1",
1593            [minimum_compatible],
1594        )
1595        .unwrap();
1596    transaction
1597        .execute(
1598            "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, 'test')",
1599            [revision],
1600        )
1601        .unwrap();
1602    transaction
1603        .pragma_update(None, "user_version", revision)
1604        .unwrap();
1605    transaction.commit().unwrap();
1606    forget_verified_schema(path);
1607}
1608
1609#[cfg(test)]
1610mod reader_tests {
1611    use super::*;
1612
1613    /// Rewrites a store's recorded schema version the way another build's
1614    /// migration ladder would, and forgets that this process verified it.
1615    fn stamp_schema_version(path: &Path, version: i64) {
1616        if version > SCHEMA_VERSION {
1617            advance_test_schema(path, version, version);
1618            return;
1619        }
1620        let connection = Connection::open(path).unwrap();
1621        connection
1622            .execute_batch(&format!("PRAGMA user_version = {version};"))
1623            .unwrap();
1624        connection
1625            .execute(
1626                "DELETE FROM schema_migrations WHERE version > ?1",
1627                [version],
1628            )
1629            .unwrap();
1630        if version == 30 {
1631            connection
1632                .execute(
1633                    "UPDATE schema_compatibility SET minimum_compatible_version = 30 WHERE singleton = 1",
1634                    [],
1635                )
1636                .unwrap();
1637        }
1638        drop(connection);
1639        forget_verified_schema(path);
1640    }
1641
1642    #[test]
1643    fn older_readers_and_reopened_writers_preserve_a_compatible_future_schema() {
1644        let directory = tempfile::tempdir().unwrap();
1645        let path = directory.path().join("mj.sqlite3");
1646        let connection = open_writer(&path).unwrap();
1647        connection
1648            .execute_batch(
1649                "CREATE TABLE future_feature(value TEXT NOT NULL);
1650                 INSERT INTO future_feature VALUES ('preserve me');",
1651            )
1652            .unwrap();
1653        drop(connection);
1654        advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1655
1656        let reader = open_reader_strict(&path).unwrap();
1657        assert_eq!(
1658            reader
1659                .query_row("SELECT value FROM future_feature", [], |row| row
1660                    .get::<_, String>(0))
1661                .unwrap(),
1662            "preserve me"
1663        );
1664        assert!(reader.execute("DELETE FROM future_feature", []).is_err());
1665        drop(reader);
1666
1667        // A repair would recreate this deliberately removed trigger. A future
1668        // schema is authoritative even when it differs from our own repairs.
1669        let raw = Connection::open(&path).unwrap();
1670        raw.execute_batch("DROP TRIGGER api_session_error_updated;")
1671            .unwrap();
1672        drop(raw);
1673        let writer = open_writer(&path).unwrap();
1674        assert!(!writer.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'api_session_error_updated')", [], |row| row.get::<_, bool>(0)).unwrap());
1675        assert_eq!(
1676            writer
1677                .query_row("SELECT value FROM future_feature", [], |row| row
1678                    .get::<_, String>(0))
1679                .unwrap(),
1680            "preserve me"
1681        );
1682        let state = read_schema_state(&writer).unwrap();
1683        assert_eq!(state.revision, SCHEMA_VERSION + 1);
1684        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1685    }
1686
1687    #[test]
1688    fn invalid_compatibility_metadata_refuses_readers_and_writers() {
1689        for alteration in [
1690            "DROP TABLE schema_compatibility",
1691            "DELETE FROM schema_compatibility",
1692            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET minimum_compatible_version = 0",
1693            "UPDATE schema_compatibility SET minimum_compatible_version = 99999",
1694            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET singleton = 2",
1695            "PRAGMA ignore_check_constraints = ON; INSERT INTO schema_compatibility VALUES (2, 30)",
1696            "DROP TABLE schema_compatibility; CREATE TABLE schema_compatibility(singleton, minimum_compatible_version); INSERT INTO schema_compatibility VALUES (1, 'invalid')",
1697            "DELETE FROM schema_migrations WHERE version = (SELECT max(version) FROM schema_migrations)",
1698        ] {
1699            for future in [false, true] {
1700                let directory = tempfile::tempdir().unwrap();
1701                let path = directory.path().join("mj.sqlite3");
1702                drop(open_writer(&path).unwrap());
1703                if future {
1704                    advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1705                }
1706                let raw = Connection::open(&path).unwrap();
1707                raw.execute_batch(alteration).unwrap();
1708                let before: i64 = raw
1709                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
1710                    .unwrap();
1711                // Exercise the cached path as well as a fresh writer open.
1712                for error in [
1713                    open_reader_strict(&path).unwrap_err(),
1714                    open_writer(&path).unwrap_err(),
1715                ] {
1716                    let mismatch = error.downcast_ref::<StoreSchemaMismatch>().unwrap();
1717                    assert_eq!(
1718                        mismatch.reason,
1719                        StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
1720                        "{alteration}"
1721                    );
1722                }
1723                forget_verified_schema(&path);
1724                assert!(open_writer(&path).is_err(), "{alteration}");
1725                let after: i64 = raw
1726                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
1727                    .unwrap();
1728                assert_eq!(
1729                    before, after,
1730                    "a rejected open repaired schema: {alteration}"
1731                );
1732            }
1733        }
1734    }
1735
1736    #[test]
1737    fn compatibility_baseline_migration_is_atomic_and_retryable() {
1738        let directory = tempfile::tempdir().unwrap();
1739        let path = directory.path().join("mj.sqlite3");
1740        let connection = open_writer(&path).unwrap();
1741        let state = read_schema_state(&connection).unwrap();
1742        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1743        connection
1744            .execute_batch(
1745                "DROP TABLE schema_compatibility;
1746             DELETE FROM schema_migrations WHERE version >= 30;
1747             PRAGMA user_version = 29;
1748             CREATE TRIGGER reject_baseline BEFORE INSERT ON schema_migrations
1749             WHEN NEW.version = 30 BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END;",
1750            )
1751            .unwrap();
1752        forget_verified_schema(&path);
1753        let error = migrate_schema(&connection).unwrap_err();
1754        assert!(error.to_string().contains("injected migration failure"));
1755        assert!(
1756            connection.is_autocommit(),
1757            "the failed migration left a transaction open"
1758        );
1759        assert_eq!(read_schema_state(&connection).unwrap().revision, 29);
1760        assert_eq!(
1761            connection
1762                .query_row("SELECT max(version) FROM schema_migrations", [], |row| row
1763                    .get::<_, i64>(
1764                    0
1765                ))
1766                .unwrap(),
1767            29
1768        );
1769        assert!(!connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'schema_compatibility')", [], |row| row.get::<_, bool>(0)).unwrap());
1770        connection
1771            .execute_batch("DROP TRIGGER reject_baseline")
1772            .unwrap();
1773        drop(connection);
1774        let writer = open_writer(&path).unwrap();
1775        let state = read_schema_state(&writer).unwrap();
1776        assert_eq!(state.revision, SCHEMA_VERSION);
1777        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1778    }
1779
1780    /// A store ahead of this build cannot be fixed by starting a daemon of
1781    /// this build, so the reader must not say so. This is the message the
1782    /// incident in #24 printed twice a second for an hour.
1783    #[test]
1784    fn strict_reader_reports_a_newer_store_without_blaming_the_daemon() {
1785        let directory = tempfile::tempdir().unwrap();
1786        let path = directory.path().join("mj.sqlite3");
1787        drop(open_writer(&path).unwrap());
1788        stamp_schema_version(&path, SCHEMA_VERSION + 1);
1789
1790        let error = open_reader_strict(&path).unwrap_err();
1791
1792        let mismatch = error
1793            .chain()
1794            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1795            .expect("the reader reports the mismatch as a typed cause");
1796        assert_eq!(mismatch.found, SCHEMA_VERSION + 1);
1797        assert_eq!(mismatch.supported, SCHEMA_VERSION);
1798        let message = mismatch.to_string();
1799        assert!(message.contains("upgrade Mjolnir"), "got {message}");
1800        assert!(
1801            !message.contains("start the Mjolnir daemon"),
1802            "got {message}"
1803        );
1804    }
1805
1806    /// A store behind this build keeps the advice that works, verbatim, so
1807    /// existing log greps and runbooks keep matching.
1808    #[test]
1809    fn strict_reader_keeps_the_migrate_advice_when_the_store_is_behind() {
1810        let directory = tempfile::tempdir().unwrap();
1811        let path = directory.path().join("mj.sqlite3");
1812        drop(open_writer(&path).unwrap());
1813        let raw = Connection::open(&path).unwrap();
1814        raw.execute_batch(&format!(
1815            "UPDATE schema_compatibility SET minimum_compatible_version = {0};
1816             DELETE FROM schema_migrations WHERE version > {0};
1817             PRAGMA user_version = {0};",
1818            SCHEMA_VERSION - 1
1819        ))
1820        .unwrap();
1821        drop(raw);
1822
1823        let error = open_reader_strict(&path).unwrap_err();
1824
1825        let mismatch = error
1826            .chain()
1827            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1828            .expect("the reader reports the mismatch as a typed cause");
1829        assert_eq!(
1830            mismatch.to_string(),
1831            format!(
1832                "Mjolnir database schema {} is not the supported schema {SCHEMA_VERSION}; \
1833                 start the Mjolnir daemon to migrate it",
1834                SCHEMA_VERSION - 1
1835            )
1836        );
1837    }
1838
1839    #[test]
1840    fn strict_reader_rejects_mutation() {
1841        let directory = tempfile::tempdir().unwrap();
1842        let path = directory.path().join("mj.sqlite3");
1843        drop(open_writer(&path).unwrap());
1844
1845        let reader = open_reader_strict(&path).unwrap();
1846        let error = reader
1847            .execute("CREATE TABLE forbidden(value TEXT)", [])
1848            .unwrap_err();
1849        assert!(
1850            matches!(
1851                error.sqlite_error_code(),
1852                Some(rusqlite::ErrorCode::ReadOnly)
1853            ),
1854            "unexpected mutation error: {error}"
1855        );
1856    }
1857}