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    // Compatible: adds one nullable column. Older readers ignore it, and the
804    // older writer's session upsert lists columns explicitly, so it preserves
805    // the value. An older executable launching such a session falls back to the
806    // global `[subagents] enabled` setting, which is a behaviour difference,
807    // not data loss. The compatibility floor stays where it is.
808    if version < 33 {
809        connection.execute_batch(
810            "BEGIN IMMEDIATE;
811             ALTER TABLE sessions ADD COLUMN mjolnir_subagents INTEGER
812                 CHECK(mjolnir_subagents IN (0, 1));
813             INSERT INTO schema_migrations(version, applied_at)
814                 VALUES (33, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
815             PRAGMA user_version = 33;
816             COMMIT;",
817        )?;
818    }
819    let recorded: Option<i64> =
820        connection.query_row("SELECT max(version) FROM schema_migrations", [], |row| {
821            row.get(0)
822        })?;
823    if recorded != Some(SCHEMA_VERSION) {
824        bail!(
825            "Mjolnir database migration ledger {:?} does not match schema {}",
826            recorded,
827            SCHEMA_VERSION
828        );
829    }
830    // A database that already applied migration 14 with a build older than the
831    // one that added `client_session_state` never got the table, since that
832    // migration only ran `ensure_workspace_schema` once, at version 14. Create
833    // it unconditionally (IF NOT EXISTS) on every writer open so an
834    // already-migrated database converges too.
835    ensure_client_session_state_schema(connection)?;
836    ensure_api_events_schema(connection)?;
837    Ok(())
838}
839
840/// Breaking baseline: earlier executables reject every newer revision.
841/// Later compatible migrations retain the floor; breaking ones raise it to
842/// their revision in the same transaction as their schema and ledger changes.
843fn migrate_compatibility_metadata(connection: &Connection) -> Result<()> {
844    let transaction = connection.unchecked_transaction()?;
845    transaction.execute_batch(
846        "CREATE TABLE schema_compatibility (
847             singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
848             minimum_compatible_version INTEGER NOT NULL CHECK(minimum_compatible_version >= 30)
849         ) STRICT;
850         INSERT INTO schema_compatibility(singleton, minimum_compatible_version) VALUES (1, 30);
851         INSERT INTO schema_migrations(version, applied_at)
852             VALUES (30, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
853         PRAGMA user_version = 30;",
854    )?;
855    transaction.commit()?;
856    Ok(())
857}
858
859/// Breaking migration: older controllers do not understand that a child
860/// borrows its parent's target and could destroy shared resources.
861fn migrate_subagent_sessions(connection: &Connection) -> Result<()> {
862    let transaction = connection.unchecked_transaction()?;
863    transaction.execute_batch(
864        "CREATE TABLE IF NOT EXISTS subagent_sessions (
865             child_session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
866             parent_session_id TEXT NOT NULL REFERENCES sessions(session_id),
867             request_key TEXT NOT NULL,
868             record_json TEXT NOT NULL CHECK(json_valid(record_json)),
869             CHECK(child_session_id <> parent_session_id),
870             UNIQUE(parent_session_id, request_key)
871         ) STRICT;
872         CREATE INDEX IF NOT EXISTS subagent_sessions_parent
873             ON subagent_sessions(parent_session_id, child_session_id);
874         UPDATE schema_compatibility SET minimum_compatible_version = 31
875             WHERE singleton = 1;
876         INSERT INTO schema_migrations(version, applied_at)
877             VALUES (31, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
878         PRAGMA user_version = 31;",
879    )?;
880    transaction.commit()?;
881    Ok(())
882}
883
884/// Breaking migration: older controllers cannot deserialize the new harness
885/// enum and their table constraints reject ZCode rows written by that build.
886///
887/// The ZCode harness has since been removed. The `'zcode'` value is retained in
888/// the `harness_kind` CHECK constraint only so session rows written by earlier
889/// releases stay readable; no code accepts it, `HarnessKind::from_str` rejects
890/// it, and `load_state_from` skips such a row with a warning. Removing the
891/// value would need another breaking migration that rewrote or deleted those
892/// rows, so it is left in place. Never rewrite this migration; give any later
893/// schema change a new revision.
894fn migrate_zcode_harness_kind(connection: &Connection) -> Result<()> {
895    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
896    let migration = (|| -> Result<()> {
897        let transaction = connection.unchecked_transaction()?;
898        for table in ["sessions", "hidden_native_sessions"] {
899            let sql: String = transaction.query_row(
900                "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
901                [table],
902                |row| row.get(0),
903            )?;
904            let (_, definition) = sql
905                .split_once('(')
906                .context("missing harness table definition")?;
907            if definition.contains("'muse','zcode')") {
908                continue;
909            }
910            ensure!(
911                definition.contains("'muse')"),
912                "unexpected {table} harness constraint"
913            );
914            let definition = definition.replace("'muse')", "'muse','zcode')");
915            let objects: Vec<String> = transaction
916                .prepare(
917                    "SELECT sql FROM sqlite_schema WHERE tbl_name=?1
918                     AND type IN ('index','trigger') AND sql IS NOT NULL",
919                )?
920                .query_map([table], |row| row.get(0))?
921                .collect::<rusqlite::Result<_>>()?;
922            transaction.execute_batch(&format!(
923                "CREATE TABLE {table}_zcode_v32 ({definition};
924                 INSERT INTO {table}_zcode_v32 SELECT * FROM {table};
925                 DROP TABLE {table};
926                 ALTER TABLE {table}_zcode_v32 RENAME TO {table};"
927            ))?;
928            for object in objects {
929                transaction.execute_batch(&object)?;
930            }
931        }
932        ensure!(
933            !transaction
934                .prepare("PRAGMA foreign_key_check")?
935                .exists([])?,
936            "foreign key violation in ZCode migration"
937        );
938        transaction.execute_batch(
939            "UPDATE schema_compatibility SET minimum_compatible_version = 32
940                 WHERE singleton = 1;
941             INSERT INTO schema_migrations(version, applied_at)
942                 VALUES (32, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
943             PRAGMA user_version = 32;",
944        )?;
945        transaction.commit()?;
946        Ok(())
947    })();
948    let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
949    migration.context("migrate ZCode harness constraints")?;
950    restored.context("restore foreign key enforcement after ZCode migration")?;
951    Ok(())
952}
953
954pub(super) fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
955    connection
956        .query_row(
957            "SELECT EXISTS(
958                 SELECT 1 FROM pragma_table_info(?1)
959                 WHERE name = ?2
960             )",
961            params![table, column],
962            |row| row.get(0),
963        )
964        .map_err(Into::into)
965}
966
967fn ensure_workspace_schema(connection: &Connection) -> Result<()> {
968    connection.execute_batch(
969        "CREATE TABLE IF NOT EXISTS workspaces (
970             workspace_id TEXT PRIMARY KEY CHECK(length(trim(workspace_id)) > 0),
971             name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 64),
972             name_key TEXT NOT NULL UNIQUE CHECK(length(trim(name_key)) BETWEEN 1 AND 64),
973             created_at TEXT NOT NULL,
974             last_opened_at TEXT NOT NULL
975         ) STRICT;
976         INSERT OR IGNORE INTO workspaces(
977             workspace_id, name, name_key, created_at, last_opened_at
978         ) VALUES (
979             'default', 'default', 'default',
980             strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
981             strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
982         );",
983    )?;
984    if !table_has_column(connection, "session_contexts", "workspace_id")? {
985        connection.execute_batch(
986            "ALTER TABLE session_contexts
987                 ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default';",
988        )?;
989    }
990    connection.execute_batch(
991        "CREATE INDEX IF NOT EXISTS session_contexts_workspace
992             ON session_contexts(workspace_id, session_id);
993         CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_insert
994         BEFORE INSERT ON session_contexts
995         WHEN NOT EXISTS(
996             SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
997         )
998         BEGIN
999             SELECT RAISE(ABORT, 'unknown workspace');
1000         END;
1001         CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_update
1002         BEFORE UPDATE OF workspace_id ON session_contexts
1003         WHEN NOT EXISTS(
1004             SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
1005         )
1006         BEGIN
1007             SELECT RAISE(ABORT, 'unknown workspace');
1008         END;
1009         CREATE TABLE IF NOT EXISTS client_read_frontiers (
1010             client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
1011             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
1012             session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
1013             through_event_ordinal INTEGER NOT NULL DEFAULT 0
1014                 CHECK(through_event_ordinal >= 0),
1015             updated_at TEXT NOT NULL,
1016             PRIMARY KEY(client_id, workspace_id, session_id)
1017         ) STRICT;
1018         CREATE TABLE IF NOT EXISTS detached_drafts (
1019             draft_id TEXT PRIMARY KEY CHECK(length(trim(draft_id)) > 0),
1020             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id),
1021             session_id TEXT REFERENCES session_contexts(session_id),
1022             source TEXT NOT NULL CHECK(length(trim(source)) > 0),
1023             owner_pid INTEGER CHECK(owner_pid IS NULL OR owner_pid > 0),
1024             saved_at TEXT NOT NULL,
1025             text TEXT NOT NULL CHECK(length(text) > 0),
1026             recovered_at TEXT
1027         ) STRICT;
1028         CREATE INDEX IF NOT EXISTS detached_drafts_workspace_recent
1029             ON detached_drafts(workspace_id, saved_at DESC);",
1030    )?;
1031    ensure_client_session_state_schema(connection)?;
1032    Ok(())
1033}
1034
1035/// Per-viewer, per-session state a web client keeps between visits.
1036///
1037/// This is additive and separate from `client_read_frontiers` on purpose. A
1038/// frontier is a cursor every client has; a draft is text one viewer typed and
1039/// did not send, and it expires. Keeping them apart means the phone's
1040/// retention policy cannot reach a terminal client's cursor.
1041fn ensure_client_session_state_schema(connection: &Connection) -> Result<()> {
1042    connection.execute_batch(
1043        "CREATE TABLE IF NOT EXISTS client_session_state (
1044             client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
1045             workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
1046             session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
1047             draft TEXT NOT NULL DEFAULT '',
1048             updated_at TEXT NOT NULL,
1049             PRIMARY KEY(client_id, workspace_id, session_id)
1050         ) STRICT;
1051         CREATE INDEX IF NOT EXISTS client_session_state_age
1052             ON client_session_state(updated_at);",
1053    )?;
1054    Ok(())
1055}
1056
1057/// Per-session container size overrides. They are additive columns, so
1058/// databases written before the dashboard could edit them open unchanged.
1059fn ensure_session_container_override_columns(connection: &Connection) -> Result<()> {
1060    for column in ["container_cpus", "container_memory"] {
1061        if !table_has_column(connection, "sessions", column)? {
1062            connection.execute_batch(&format!(
1063                "BEGIN IMMEDIATE;
1064                 ALTER TABLE sessions ADD COLUMN {column} TEXT;
1065                 COMMIT;"
1066            ))?;
1067        }
1068    }
1069    Ok(())
1070}
1071
1072/// Per-mount read-only flag. It is an additive column, so a database written
1073/// before the mount editors offered the option opens unchanged and its mounts
1074/// keep the copy-on-write overlay they were provisioned with.
1075/// Add the per-turn identity and outcome columns, the queue's acceptance
1076/// ordinal, and the API idempotency ledger.
1077///
1078/// Each addition is guarded by a structural check rather than by the version
1079/// alone. A database rebuilt by another build's ladder — or by a test that
1080/// rewinds `user_version` — can already carry some of these, and a bare
1081/// `ALTER TABLE` would then fail the whole open.
1082fn migrate_turn_outcome_columns(connection: &Connection) -> Result<()> {
1083    let mut statements = String::from("BEGIN IMMEDIATE;\n");
1084    if !table_has_column(connection, "materialized_sessions", "active_turn_json")? {
1085        statements.push_str(
1086            "ALTER TABLE materialized_sessions ADD COLUMN active_turn_json TEXT
1087                 CHECK(active_turn_json IS NULL OR json_valid(active_turn_json));\n",
1088        );
1089    }
1090    if !table_has_column(
1091        connection,
1092        "materialized_sessions",
1093        "last_turn_outcome_json",
1094    )? {
1095        statements.push_str(
1096            "ALTER TABLE materialized_sessions ADD COLUMN last_turn_outcome_json TEXT
1097                 CHECK(last_turn_outcome_json IS NULL OR json_valid(last_turn_outcome_json));\n",
1098        );
1099    }
1100    if !table_has_column(
1101        connection,
1102        "materialized_queued_prompts",
1103        "accepted_ordinal",
1104    )? {
1105        statements.push_str(
1106            "ALTER TABLE materialized_queued_prompts ADD COLUMN accepted_ordinal INTEGER
1107                 CHECK(accepted_ordinal IS NULL OR accepted_ordinal > 0);\n",
1108        );
1109    }
1110    statements.push_str(
1111        "CREATE TABLE IF NOT EXISTS api_idempotency (
1112             key TEXT PRIMARY KEY CHECK(length(trim(key)) BETWEEN 1 AND 128),
1113             session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1114             created_at_ms INTEGER NOT NULL
1115         ) STRICT;
1116         INSERT INTO schema_migrations(version, applied_at)
1117             VALUES (28, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1118         PRAGMA user_version = 28;
1119         COMMIT;",
1120    );
1121    connection.execute_batch(&statements)?;
1122    Ok(())
1123}
1124
1125fn ensure_session_mount_read_only_column(connection: &Connection) -> Result<()> {
1126    if !table_has_column(connection, "session_mounts", "read_only")? {
1127        connection.execute_batch(
1128            "BEGIN IMMEDIATE;
1129             ALTER TABLE session_mounts ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0;
1130             COMMIT;",
1131        )?;
1132    }
1133    Ok(())
1134}
1135
1136fn ensure_materialized_elicitation_column(connection: &Connection) -> Result<()> {
1137    if !table_has_column(
1138        connection,
1139        "materialized_sessions",
1140        "pending_elicitations_json",
1141    )? {
1142        connection.execute_batch(
1143            "BEGIN IMMEDIATE;
1144             ALTER TABLE materialized_sessions
1145                 ADD COLUMN pending_elicitations_json TEXT NOT NULL DEFAULT '[]';
1146             COMMIT;",
1147        )?;
1148    }
1149    Ok(())
1150}
1151
1152fn ensure_managed_worktree_column(connection: &Connection) -> Result<()> {
1153    if !table_has_column(connection, "sessions", "managed_worktree")? {
1154        connection.execute_batch(
1155            "BEGIN IMMEDIATE;
1156             ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
1157             COMMIT;",
1158        )?;
1159    }
1160    Ok(())
1161}
1162
1163/// Complete the relay half of the colliding v6 migration for databases first
1164/// opened by master, whose v6 contained only `managed_worktree`.
1165fn ensure_relay_projection_schema(connection: &Connection) -> Result<()> {
1166    if table_has_column(connection, "sessions", "detached_after_event_ordinal")? {
1167        return Ok(());
1168    }
1169    connection.execute_batch(&format!(
1170        "BEGIN IMMEDIATE;
1171         ALTER TABLE session_checkpoints
1172             RENAME COLUMN event_sequence TO event_frontier;
1173         ALTER TABLE prompt_history
1174             RENAME COLUMN event_sequence TO event_ordinal;
1175         ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
1176             DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
1177         CREATE TABLE materialized_sessions (
1178             session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1179             applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
1180             applied_event_digest TEXT NOT NULL
1181                 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1182                 CHECK(length(applied_event_digest) = 64
1183                       AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
1184             last_activity_at_ms INTEGER,
1185             execution_state TEXT NOT NULL DEFAULT 'idle'
1186                 CHECK(execution_state IN ('idle','running','closing','closed')),
1187             running_started_at_ms INTEGER,
1188             session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
1189             configuration_json TEXT NOT NULL DEFAULT '{{}}',
1190             CHECK(
1191                 (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
1192                 OR (execution_state != 'running' AND running_started_at_ms IS NULL)
1193             )
1194         ) STRICT;
1195         CREATE TABLE materialized_transcript_items (
1196             session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1197             stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
1198             position INTEGER NOT NULL CHECK(position > 0),
1199             latest_content_event_ordinal INTEGER
1200                 CHECK(latest_content_event_ordinal IS NULL
1201                       OR latest_content_event_ordinal >= position),
1202             created_at_ms INTEGER NOT NULL,
1203             last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
1204             body_json TEXT NOT NULL,
1205             PRIMARY KEY(session_id, stable_id)
1206         ) STRICT;
1207         CREATE INDEX materialized_transcript_position
1208             ON materialized_transcript_items(session_id, position, stable_id);
1209         CREATE TABLE materialized_queued_prompts (
1210             session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1211             ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
1212             command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
1213             content_json TEXT NOT NULL,
1214             queued_at_ms INTEGER NOT NULL,
1215             PRIMARY KEY(session_id, ordinal),
1216             UNIQUE(session_id, command_id)
1217         ) STRICT;
1218         INSERT INTO materialized_sessions(session_id)
1219             SELECT session_id FROM sessions;
1220         COMMIT;",
1221    ))?;
1222    Ok(())
1223}
1224
1225fn migrate_destroying_session_state(connection: &Connection) -> Result<()> {
1226    // SQLite cannot widen a CHECK constraint in place. Foreign keys are
1227    // disabled only around the standard table-rebuild transaction; every
1228    // child continues to reference the replacement table by the same name.
1229    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1230    let migration = connection.execute_batch(
1231        "BEGIN IMMEDIATE;
1232         CREATE TABLE sessions_v7 (
1233             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1234             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1235             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
1236             last_profile TEXT NOT NULL,
1237             target_template_id TEXT NOT NULL,
1238             state TEXT NOT NULL CHECK(state IN (
1239                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1240                 'archived','lost','error','destroyed-with-data-loss'
1241             )),
1242             native_session_id TEXT,
1243             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1244             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1245             updated_at TEXT NOT NULL,
1246             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1247                 CHECK(detached_after_event_ordinal >= 0),
1248             last_error TEXT,
1249             resource_allocation TEXT,
1250             last_checkpoint_error TEXT,
1251             project_directory BLOB,
1252             managed_worktree TEXT
1253         ) STRICT;
1254         INSERT INTO sessions_v7(
1255             session_id, title, harness_kind, last_profile, target_template_id, state,
1256             native_session_id, acp_session_title, session_title_override, updated_at,
1257             detached_after_event_ordinal, last_error, resource_allocation,
1258             last_checkpoint_error, project_directory, managed_worktree
1259         )
1260         SELECT
1261             session_id, title, harness_kind, last_profile, target_template_id, state,
1262             native_session_id, acp_session_title, session_title_override, updated_at,
1263             detached_after_event_ordinal, last_error, resource_allocation,
1264             last_checkpoint_error, project_directory, managed_worktree
1265         FROM sessions;
1266         DROP TABLE sessions;
1267         ALTER TABLE sessions_v7 RENAME TO sessions;
1268         INSERT INTO schema_migrations(version, applied_at)
1269             VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1270         PRAGMA user_version = 7;
1271         COMMIT;",
1272    );
1273    if migration.is_err()
1274        && let Err(error) = connection.execute_batch("ROLLBACK;")
1275    {
1276        tracing::warn!(%error, "could not roll back durable-destroying-session migration");
1277    }
1278    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1279    migration.context("migrate durable destroying session state")?;
1280    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1281    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1282    if statement.exists([])? {
1283        bail!("foreign key violation after migrating durable destroying session state");
1284    }
1285    Ok(())
1286}
1287
1288/// Admit the Grok Build harness. SQLite cannot widen a CHECK constraint in
1289/// place, so this repeats the v7 table rebuild with the wider harness list.
1290/// Foreign keys are disabled only around the rebuild transaction; every child
1291/// continues to reference the replacement table by the same name.
1292fn migrate_grok_harness_kind(connection: &Connection) -> Result<()> {
1293    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1294    let migration = connection.execute_batch(
1295        "BEGIN IMMEDIATE;
1296         CREATE TABLE sessions_v9 (
1297             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1298             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1299             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1300             last_profile TEXT NOT NULL,
1301             target_template_id TEXT NOT NULL,
1302             state TEXT NOT NULL CHECK(state IN (
1303                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1304                 'archived','lost','error','destroyed-with-data-loss'
1305             )),
1306             native_session_id TEXT,
1307             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1308             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1309             updated_at TEXT NOT NULL,
1310             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1311                 CHECK(detached_after_event_ordinal >= 0),
1312             last_error TEXT,
1313             resource_allocation TEXT,
1314             last_checkpoint_error TEXT,
1315             project_directory BLOB,
1316             managed_worktree TEXT,
1317             draft_input TEXT NOT NULL DEFAULT ''
1318         ) STRICT;
1319         INSERT INTO sessions_v9(
1320             session_id, title, harness_kind, last_profile, target_template_id, state,
1321             native_session_id, acp_session_title, session_title_override, updated_at,
1322             detached_after_event_ordinal, last_error, resource_allocation,
1323             last_checkpoint_error, project_directory, managed_worktree, draft_input
1324         )
1325         SELECT
1326             session_id, title, harness_kind, last_profile, target_template_id, state,
1327             native_session_id, acp_session_title, session_title_override, updated_at,
1328             detached_after_event_ordinal, last_error, resource_allocation,
1329             last_checkpoint_error, project_directory, managed_worktree, draft_input
1330         FROM sessions;
1331         DROP TABLE sessions;
1332         ALTER TABLE sessions_v9 RENAME TO sessions;
1333         INSERT INTO schema_migrations(version, applied_at)
1334             VALUES (9, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1335         PRAGMA user_version = 9;
1336         COMMIT;",
1337    );
1338    if migration.is_err()
1339        && let Err(error) = connection.execute_batch("ROLLBACK;")
1340    {
1341        tracing::warn!(%error, "could not roll back Grok harness migration");
1342    }
1343    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1344    migration.context("migrate sessions table for the Grok Build harness")?;
1345    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1346    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1347    if statement.exists([])? {
1348        bail!("foreign key violation after migrating the sessions harness list");
1349    }
1350    Ok(())
1351}
1352
1353/// Rename the `archived` lifecycle state to `stopped` and give sessions their
1354/// own display-only `archived` flag, which now means "hidden from the resume
1355/// dialog". SQLite cannot narrow or widen a CHECK constraint in place, so this
1356/// repeats the v9 table rebuild with the new state list and the new column.
1357/// It also adds the hidden set for native sessions Mjolnir only reads.
1358fn migrate_stopped_session_state(connection: &Connection) -> Result<()> {
1359    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1360    let migration = connection.execute_batch(
1361        "BEGIN IMMEDIATE;
1362         CREATE TABLE sessions_v10 (
1363             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1364             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1365             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1366             last_profile TEXT NOT NULL,
1367             target_template_id TEXT NOT NULL,
1368             state TEXT NOT NULL CHECK(state IN (
1369                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1370                 'stopped','lost','error','destroyed-with-data-loss'
1371             )),
1372             native_session_id TEXT,
1373             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1374             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1375             updated_at TEXT NOT NULL,
1376             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1377                 CHECK(detached_after_event_ordinal >= 0),
1378             last_error TEXT,
1379             resource_allocation TEXT,
1380             last_checkpoint_error TEXT,
1381             project_directory BLOB,
1382             managed_worktree TEXT,
1383             draft_input TEXT NOT NULL DEFAULT '',
1384             container_cpus TEXT,
1385             container_memory TEXT,
1386             archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1387         ) STRICT;
1388         INSERT INTO sessions_v10(
1389             session_id, title, harness_kind, last_profile, target_template_id, state,
1390             native_session_id, acp_session_title, session_title_override, updated_at,
1391             detached_after_event_ordinal, last_error, resource_allocation,
1392             last_checkpoint_error, project_directory, managed_worktree, draft_input,
1393             container_cpus, container_memory
1394         )
1395         SELECT
1396             session_id, title, harness_kind, last_profile, target_template_id,
1397             CASE state WHEN 'archived' THEN 'stopped' ELSE state END,
1398             native_session_id, acp_session_title, session_title_override, updated_at,
1399             detached_after_event_ordinal, last_error, resource_allocation,
1400             last_checkpoint_error, project_directory, managed_worktree, draft_input,
1401             container_cpus, container_memory
1402         FROM sessions;
1403         DROP TABLE sessions;
1404         ALTER TABLE sessions_v10 RENAME TO sessions;
1405         CREATE TABLE hidden_native_sessions (
1406             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1407             native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1408             hidden_at TEXT NOT NULL,
1409             PRIMARY KEY(harness_kind, native_session_id)
1410         ) STRICT;
1411         INSERT INTO schema_migrations(version, applied_at)
1412             VALUES (10, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1413         PRAGMA user_version = 10;
1414         COMMIT;",
1415    );
1416    if migration.is_err()
1417        && let Err(error) = connection.execute_batch("ROLLBACK;")
1418    {
1419        tracing::warn!(%error, "could not roll back stopped-session migration");
1420    }
1421    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1422    migration.context("migrate sessions table for the stopped session state")?;
1423    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1424    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1425    if statement.exists([])? {
1426        bail!("foreign key violation after migrating the stopped session state");
1427    }
1428    Ok(())
1429}
1430
1431/// Preserve existing data and dependent indexes while admitting Muse sessions.
1432///
1433/// The widened constraint this builds still lists `'deepseek'`, carried over
1434/// from migration 11. The DSH harness has since been removed; the value is
1435/// retained only so session rows written by earlier releases stay readable.
1436/// No code accepts it, `HarnessKind::from_str` rejects it, and `load_state_from`
1437/// skips such a row with a warning. Dropping the value would need another
1438/// breaking migration that rewrote or deleted those rows.
1439fn migrate_muse_harness_kind(connection: &Connection) -> Result<()> {
1440    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1441    let migration = (|| -> Result<()> {
1442        let transaction = connection.unchecked_transaction()?;
1443        for table in ["sessions", "hidden_native_sessions"] {
1444            let sql: String = transaction.query_row(
1445                "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
1446                [table],
1447                |row| row.get(0),
1448            )?;
1449            let (_, definition) = sql
1450                .split_once('(')
1451                .context("missing harness table definition")?;
1452            if definition.contains("'deepseek','muse')") {
1453                continue;
1454            }
1455            ensure!(
1456                definition.contains("'deepseek')"),
1457                "unexpected {table} harness constraint"
1458            );
1459            let definition = definition.replace("'deepseek')", "'deepseek','muse')");
1460            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")?
1461                .query_map([table], |row| row.get(0))?.collect::<rusqlite::Result<_>>()?;
1462            transaction.execute_batch(&format!(
1463                "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};"
1464            ))?;
1465            for object in objects {
1466                transaction.execute_batch(&object)?;
1467            }
1468        }
1469        ensure!(
1470            !transaction
1471                .prepare("PRAGMA foreign_key_check")?
1472                .exists([])?,
1473            "foreign key violation in Muse migration"
1474        );
1475        transaction.execute_batch("INSERT INTO schema_migrations(version, applied_at) VALUES (27, strftime('%Y-%m-%dT%H:%M:%fZ','now')); PRAGMA user_version = 27;")?;
1476        transaction.commit()?;
1477        Ok(())
1478    })();
1479    let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1480    migration.context("migrate Muse harness constraints")?;
1481    restored.context("restore foreign key enforcement after Muse migration")?;
1482    Ok(())
1483}
1484
1485/// Admit DeepSeek Harness in both stored sessions and Mjolnir's native-session
1486/// hidden set. SQLite requires rebuilding tables to widen CHECK constraints.
1487fn migrate_deepseek_harness_kind(connection: &Connection) -> Result<()> {
1488    connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1489    let migration = connection.execute_batch(
1490        "BEGIN IMMEDIATE;
1491         CREATE TABLE sessions_v11 (
1492             session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1493             title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1494             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1495             last_profile TEXT NOT NULL,
1496             target_template_id TEXT NOT NULL,
1497             state TEXT NOT NULL CHECK(state IN (
1498                 'provisioning','running','disconnected','checkpointing','closing','destroying',
1499                 'stopped','lost','error','destroyed-with-data-loss'
1500             )),
1501             native_session_id TEXT,
1502             acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1503             session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1504             updated_at TEXT NOT NULL,
1505             detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1506                 CHECK(detached_after_event_ordinal >= 0),
1507             last_error TEXT,
1508             resource_allocation TEXT,
1509             last_checkpoint_error TEXT,
1510             project_directory BLOB,
1511             managed_worktree TEXT,
1512             draft_input TEXT NOT NULL DEFAULT '',
1513             container_cpus TEXT,
1514             container_memory TEXT,
1515             archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1516         ) STRICT;
1517         INSERT INTO sessions_v11 SELECT * FROM sessions;
1518         DROP TABLE sessions;
1519         ALTER TABLE sessions_v11 RENAME TO sessions;
1520         ALTER TABLE hidden_native_sessions RENAME TO hidden_native_sessions_v10;
1521         CREATE TABLE hidden_native_sessions (
1522             harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1523             native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1524             hidden_at TEXT NOT NULL,
1525             PRIMARY KEY(harness_kind, native_session_id)
1526         ) STRICT;
1527         INSERT INTO hidden_native_sessions SELECT * FROM hidden_native_sessions_v10;
1528         DROP TABLE hidden_native_sessions_v10;
1529         INSERT INTO schema_migrations(version, applied_at)
1530             VALUES (11, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1531         PRAGMA user_version = 11;
1532         COMMIT;",
1533    );
1534    if migration.is_err()
1535        && let Err(error) = connection.execute_batch("ROLLBACK;")
1536    {
1537        tracing::warn!(%error, "could not roll back DeepSeek harness migration");
1538    }
1539    let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1540    migration.context("migrate sessions table for DeepSeek Harness")?;
1541    foreign_keys.context("restore foreign key enforcement after schema migration")?;
1542    let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1543    if statement.exists([])? {
1544        bail!("foreign key violation after migrating the DeepSeek Harness list");
1545    }
1546    Ok(())
1547}
1548
1549/// Carry unsent chat input across a detach. Added as a structural guard rather
1550/// than a new schema version so databases written by either development line
1551/// converge, matching `ensure_managed_worktree_column`.
1552fn ensure_session_draft_input_column(connection: &Connection) -> Result<()> {
1553    if !table_has_column(connection, "sessions", "draft_input")? {
1554        connection.execute_batch(
1555            "BEGIN IMMEDIATE;
1556             ALTER TABLE sessions ADD COLUMN draft_input TEXT NOT NULL DEFAULT '';
1557             COMMIT;",
1558        )?;
1559    }
1560    Ok(())
1561}
1562
1563fn ensure_projection_digest_column(connection: &Connection) -> Result<()> {
1564    let present = connection.query_row(
1565        "SELECT EXISTS(
1566             SELECT 1 FROM pragma_table_info('materialized_sessions')
1567             WHERE name = 'applied_event_digest'
1568         )",
1569        [],
1570        |row| row.get::<_, bool>(0),
1571    )?;
1572    if !present {
1573        connection.execute_batch(&format!(
1574            "BEGIN IMMEDIATE;
1575             ALTER TABLE materialized_sessions ADD COLUMN applied_event_digest TEXT NOT NULL
1576                 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1577                 CHECK(length(applied_event_digest) = 64
1578                       AND applied_event_digest NOT GLOB '*[^0-9a-f]*');
1579             COMMIT;",
1580        ))?;
1581    }
1582    Ok(())
1583}
1584
1585fn ensure_api_events_schema(connection: &Connection) -> Result<()> {
1586    connection.execute_batch(
1587        "CREATE TABLE IF NOT EXISTS api_events (
1588            seq INTEGER PRIMARY KEY AUTOINCREMENT,
1589            session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1590            recorded_at_ms INTEGER NOT NULL,
1591            body TEXT NOT NULL CHECK(json_valid(body))
1592        ) STRICT;
1593        CREATE INDEX IF NOT EXISTS api_events_session ON api_events(session_id, seq);
1594        CREATE TABLE IF NOT EXISTS api_session_activity (
1595            session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1596            body TEXT NOT NULL CHECK(json_valid(body))
1597        ) STRICT;
1598        CREATE TRIGGER IF NOT EXISTS api_session_error_updated
1599        AFTER UPDATE OF last_error ON sessions
1600        WHEN NEW.last_error IS NOT NULL AND NEW.last_error IS NOT OLD.last_error
1601        BEGIN
1602            INSERT INTO api_events(session_id, recorded_at_ms, body)
1603            VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1604                json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1605        END;
1606        CREATE TRIGGER IF NOT EXISTS api_session_error_inserted
1607        AFTER INSERT ON sessions WHEN NEW.last_error IS NOT NULL
1608        BEGIN
1609            INSERT INTO api_events(session_id, recorded_at_ms, body)
1610            VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1611                json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1612        END;"
1613    )?;
1614    Ok(())
1615}
1616
1617#[cfg(test)]
1618pub(super) fn advance_test_schema(path: &Path, revision: i64, minimum_compatible: i64) {
1619    let connection = Connection::open(path).unwrap();
1620    let transaction = connection.unchecked_transaction().unwrap();
1621    transaction
1622        .execute(
1623            "UPDATE schema_compatibility SET minimum_compatible_version = ?1",
1624            [minimum_compatible],
1625        )
1626        .unwrap();
1627    transaction
1628        .execute(
1629            "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, 'test')",
1630            [revision],
1631        )
1632        .unwrap();
1633    transaction
1634        .pragma_update(None, "user_version", revision)
1635        .unwrap();
1636    transaction.commit().unwrap();
1637    forget_verified_schema(path);
1638}
1639
1640#[cfg(test)]
1641mod reader_tests {
1642    use super::*;
1643
1644    /// The oldest executable revision that can still read and write a store at
1645    /// `SCHEMA_VERSION`. Migration 32 (ZCode) was the last breaking one; the
1646    /// compatible migrations after it leave the floor where it is.
1647    const MINIMUM_COMPATIBLE_VERSION: i64 = 32;
1648
1649    /// Rewrites a store's recorded schema version the way another build's
1650    /// migration ladder would, and forgets that this process verified it.
1651    fn stamp_schema_version(path: &Path, version: i64) {
1652        if version > SCHEMA_VERSION {
1653            advance_test_schema(path, version, version);
1654            return;
1655        }
1656        let connection = Connection::open(path).unwrap();
1657        connection
1658            .execute_batch(&format!("PRAGMA user_version = {version};"))
1659            .unwrap();
1660        connection
1661            .execute(
1662                "DELETE FROM schema_migrations WHERE version > ?1",
1663                [version],
1664            )
1665            .unwrap();
1666        if version == 30 {
1667            connection
1668                .execute(
1669                    "UPDATE schema_compatibility SET minimum_compatible_version = 30 WHERE singleton = 1",
1670                    [],
1671                )
1672                .unwrap();
1673        }
1674        drop(connection);
1675        forget_verified_schema(path);
1676    }
1677
1678    #[test]
1679    fn older_readers_and_reopened_writers_preserve_a_compatible_future_schema() {
1680        let directory = tempfile::tempdir().unwrap();
1681        let path = directory.path().join("mj.sqlite3");
1682        let connection = open_writer(&path).unwrap();
1683        connection
1684            .execute_batch(
1685                "CREATE TABLE future_feature(value TEXT NOT NULL);
1686                 INSERT INTO future_feature VALUES ('preserve me');",
1687            )
1688            .unwrap();
1689        drop(connection);
1690        advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1691
1692        let reader = open_reader_strict(&path).unwrap();
1693        assert_eq!(
1694            reader
1695                .query_row("SELECT value FROM future_feature", [], |row| row
1696                    .get::<_, String>(0))
1697                .unwrap(),
1698            "preserve me"
1699        );
1700        assert!(reader.execute("DELETE FROM future_feature", []).is_err());
1701        drop(reader);
1702
1703        // A repair would recreate this deliberately removed trigger. A future
1704        // schema is authoritative even when it differs from our own repairs.
1705        let raw = Connection::open(&path).unwrap();
1706        raw.execute_batch("DROP TRIGGER api_session_error_updated;")
1707            .unwrap();
1708        drop(raw);
1709        let writer = open_writer(&path).unwrap();
1710        assert!(!writer.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'api_session_error_updated')", [], |row| row.get::<_, bool>(0)).unwrap());
1711        assert_eq!(
1712            writer
1713                .query_row("SELECT value FROM future_feature", [], |row| row
1714                    .get::<_, String>(0))
1715                .unwrap(),
1716            "preserve me"
1717        );
1718        let state = read_schema_state(&writer).unwrap();
1719        assert_eq!(state.revision, SCHEMA_VERSION + 1);
1720        assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1721    }
1722
1723    #[test]
1724    fn invalid_compatibility_metadata_refuses_readers_and_writers() {
1725        for alteration in [
1726            "DROP TABLE schema_compatibility",
1727            "DELETE FROM schema_compatibility",
1728            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET minimum_compatible_version = 0",
1729            "UPDATE schema_compatibility SET minimum_compatible_version = 99999",
1730            "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET singleton = 2",
1731            "PRAGMA ignore_check_constraints = ON; INSERT INTO schema_compatibility VALUES (2, 30)",
1732            "DROP TABLE schema_compatibility; CREATE TABLE schema_compatibility(singleton, minimum_compatible_version); INSERT INTO schema_compatibility VALUES (1, 'invalid')",
1733            "DELETE FROM schema_migrations WHERE version = (SELECT max(version) FROM schema_migrations)",
1734        ] {
1735            for future in [false, true] {
1736                let directory = tempfile::tempdir().unwrap();
1737                let path = directory.path().join("mj.sqlite3");
1738                drop(open_writer(&path).unwrap());
1739                if future {
1740                    advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1741                }
1742                let raw = Connection::open(&path).unwrap();
1743                raw.execute_batch(alteration).unwrap();
1744                let before: i64 = raw
1745                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
1746                    .unwrap();
1747                // Exercise the cached path as well as a fresh writer open.
1748                for error in [
1749                    open_reader_strict(&path).unwrap_err(),
1750                    open_writer(&path).unwrap_err(),
1751                ] {
1752                    let mismatch = error.downcast_ref::<StoreSchemaMismatch>().unwrap();
1753                    assert_eq!(
1754                        mismatch.reason,
1755                        StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
1756                        "{alteration}"
1757                    );
1758                }
1759                forget_verified_schema(&path);
1760                assert!(open_writer(&path).is_err(), "{alteration}");
1761                let after: i64 = raw
1762                    .query_row("PRAGMA schema_version", [], |row| row.get(0))
1763                    .unwrap();
1764                assert_eq!(
1765                    before, after,
1766                    "a rejected open repaired schema: {alteration}"
1767                );
1768            }
1769        }
1770    }
1771
1772    #[test]
1773    fn compatibility_baseline_migration_is_atomic_and_retryable() {
1774        let directory = tempfile::tempdir().unwrap();
1775        let path = directory.path().join("mj.sqlite3");
1776        let connection = open_writer(&path).unwrap();
1777        let state = read_schema_state(&connection).unwrap();
1778        assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
1779        connection
1780            .execute_batch(
1781                "DROP TABLE schema_compatibility;
1782             ALTER TABLE sessions DROP COLUMN mjolnir_subagents;
1783             DELETE FROM schema_migrations WHERE version >= 30;
1784             PRAGMA user_version = 29;
1785             CREATE TRIGGER reject_baseline BEFORE INSERT ON schema_migrations
1786             WHEN NEW.version = 30 BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END;",
1787            )
1788            .unwrap();
1789        forget_verified_schema(&path);
1790        let error = migrate_schema(&connection).unwrap_err();
1791        assert!(error.to_string().contains("injected migration failure"));
1792        assert!(
1793            connection.is_autocommit(),
1794            "the failed migration left a transaction open"
1795        );
1796        assert_eq!(read_schema_state(&connection).unwrap().revision, 29);
1797        assert_eq!(
1798            connection
1799                .query_row("SELECT max(version) FROM schema_migrations", [], |row| row
1800                    .get::<_, i64>(
1801                    0
1802                ))
1803                .unwrap(),
1804            29
1805        );
1806        assert!(!connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'schema_compatibility')", [], |row| row.get::<_, bool>(0)).unwrap());
1807        connection
1808            .execute_batch("DROP TRIGGER reject_baseline")
1809            .unwrap();
1810        drop(connection);
1811        let writer = open_writer(&path).unwrap();
1812        let state = read_schema_state(&writer).unwrap();
1813        assert_eq!(state.revision, SCHEMA_VERSION);
1814        assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
1815    }
1816
1817    /// A store ahead of this build cannot be fixed by starting a daemon of
1818    /// this build, so the reader must not say so. This is the message the
1819    /// incident in #24 printed twice a second for an hour.
1820    #[test]
1821    fn strict_reader_reports_a_newer_store_without_blaming_the_daemon() {
1822        let directory = tempfile::tempdir().unwrap();
1823        let path = directory.path().join("mj.sqlite3");
1824        drop(open_writer(&path).unwrap());
1825        stamp_schema_version(&path, SCHEMA_VERSION + 1);
1826
1827        let error = open_reader_strict(&path).unwrap_err();
1828
1829        let mismatch = error
1830            .chain()
1831            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1832            .expect("the reader reports the mismatch as a typed cause");
1833        assert_eq!(mismatch.found, SCHEMA_VERSION + 1);
1834        assert_eq!(mismatch.supported, SCHEMA_VERSION);
1835        let message = mismatch.to_string();
1836        assert!(message.contains("upgrade Mjolnir"), "got {message}");
1837        assert!(
1838            !message.contains("start the Mjolnir daemon"),
1839            "got {message}"
1840        );
1841    }
1842
1843    /// A store behind this build keeps the advice that works, verbatim, so
1844    /// existing log greps and runbooks keep matching.
1845    #[test]
1846    fn strict_reader_keeps_the_migrate_advice_when_the_store_is_behind() {
1847        let directory = tempfile::tempdir().unwrap();
1848        let path = directory.path().join("mj.sqlite3");
1849        drop(open_writer(&path).unwrap());
1850        let raw = Connection::open(&path).unwrap();
1851        raw.execute_batch(&format!(
1852            "UPDATE schema_compatibility SET minimum_compatible_version = {0};
1853             DELETE FROM schema_migrations WHERE version > {0};
1854             PRAGMA user_version = {0};",
1855            SCHEMA_VERSION - 1
1856        ))
1857        .unwrap();
1858        drop(raw);
1859
1860        let error = open_reader_strict(&path).unwrap_err();
1861
1862        let mismatch = error
1863            .chain()
1864            .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1865            .expect("the reader reports the mismatch as a typed cause");
1866        assert_eq!(
1867            mismatch.to_string(),
1868            format!(
1869                "Mjolnir database schema {} is not the supported schema {SCHEMA_VERSION}; \
1870                 start the Mjolnir daemon to migrate it",
1871                SCHEMA_VERSION - 1
1872            )
1873        );
1874    }
1875
1876    #[test]
1877    fn strict_reader_rejects_mutation() {
1878        let directory = tempfile::tempdir().unwrap();
1879        let path = directory.path().join("mj.sqlite3");
1880        drop(open_writer(&path).unwrap());
1881
1882        let reader = open_reader_strict(&path).unwrap();
1883        let error = reader
1884            .execute("CREATE TABLE forbidden(value TEXT)", [])
1885            .unwrap_err();
1886        assert!(
1887            matches!(
1888                error.sqlite_error_code(),
1889                Some(rusqlite::ErrorCode::ReadOnly)
1890            ),
1891            "unexpected mutation error: {error}"
1892        );
1893    }
1894}