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