Skip to main content

microclaw_engine/internal/storage/db/
schema.rs

1//! Schema bootstrap and versioned migrations.
2//!
3//! The `if version < N` blocks in `apply_schema_migrations` are FROZEN TEXT:
4//! never reformat, reorder, or "clean up" historical migrations. The upgrade
5//! path from every deployed version must replay byte-identically. New schema
6//! changes append a new version block and bump `SCHEMA_VERSION_CURRENT`.
7
8use super::*;
9
10pub(crate) const SCHEMA_VERSION_CURRENT: i64 = 43;
11
12pub(crate) fn table_has_column(
13    conn: &Connection,
14    table: &str,
15    column: &str,
16) -> Result<bool, MicroClawError> {
17    // Validate table name to prevent SQL injection via PRAGMA
18    if !table.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
19        return Err(MicroClawError::Config(format!(
20            "invalid table name: {}",
21            table
22        )));
23    }
24    // PRAGMA does not support parameter binding, so format! is required here.
25    // The table name validation above ensures only safe identifiers reach this point.
26    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
27    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
28    for col in rows {
29        if col? == column {
30            return Ok(true);
31        }
32    }
33    Ok(false)
34}
35
36pub(crate) fn ensure_memory_schema(conn: &Connection) -> Result<(), MicroClawError> {
37    if !table_has_column(conn, "memories", "embedding_model")? {
38        conn.execute("ALTER TABLE memories ADD COLUMN embedding_model TEXT", [])?;
39    }
40    if !table_has_column(conn, "memories", "chat_channel")? {
41        conn.execute("ALTER TABLE memories ADD COLUMN chat_channel TEXT", [])?;
42    }
43    if !table_has_column(conn, "memories", "external_chat_id")? {
44        conn.execute("ALTER TABLE memories ADD COLUMN external_chat_id TEXT", [])?;
45    }
46    if !table_has_column(conn, "memories", "confidence")? {
47        conn.execute("ALTER TABLE memories ADD COLUMN confidence REAL", [])?;
48    }
49    if !table_has_column(conn, "memories", "source")? {
50        conn.execute("ALTER TABLE memories ADD COLUMN source TEXT", [])?;
51    }
52    if !table_has_column(conn, "memories", "last_seen_at")? {
53        conn.execute("ALTER TABLE memories ADD COLUMN last_seen_at TEXT", [])?;
54    }
55    if !table_has_column(conn, "memories", "is_archived")? {
56        conn.execute("ALTER TABLE memories ADD COLUMN is_archived INTEGER", [])?;
57    }
58    if !table_has_column(conn, "memories", "archived_at")? {
59        conn.execute("ALTER TABLE memories ADD COLUMN archived_at TEXT", [])?;
60    }
61    if !table_has_column(conn, "memories", "expires_at")? {
62        conn.execute("ALTER TABLE memories ADD COLUMN expires_at TEXT", [])?;
63    }
64    conn.execute(
65        "UPDATE memories
66         SET confidence = COALESCE(confidence, 0.70),
67             source = COALESCE(NULLIF(source, ''), 'legacy'),
68             last_seen_at = COALESCE(last_seen_at, updated_at, created_at),
69             is_archived = COALESCE(is_archived, 0)
70         WHERE confidence IS NULL
71            OR source IS NULL OR trim(source) = ''
72            OR last_seen_at IS NULL
73            OR is_archived IS NULL",
74        [],
75    )?;
76    let chats_has_channel = table_has_column(conn, "chats", "channel")?;
77    let chats_has_external = table_has_column(conn, "chats", "external_chat_id")?;
78    if chats_has_channel && chats_has_external {
79        conn.execute(
80            "UPDATE memories
81             SET chat_channel = (
82                     SELECT c.channel FROM chats c WHERE c.chat_id = memories.chat_id
83                 ),
84                 external_chat_id = (
85                     SELECT c.external_chat_id FROM chats c WHERE c.chat_id = memories.chat_id
86                 )
87             WHERE chat_id IS NOT NULL
88               AND (
89                   chat_channel IS NULL
90                   OR trim(chat_channel) = ''
91                   OR external_chat_id IS NULL
92                   OR trim(external_chat_id) = ''
93               )",
94            [],
95        )?;
96    }
97    Ok(())
98}
99
100pub(crate) fn infer_channel_from_chat_type(chat_type: &str) -> &'static str {
101    if chat_type.starts_with("telegram_")
102        || matches!(chat_type, "private" | "group" | "supergroup" | "channel")
103    {
104        "telegram"
105    } else if chat_type == "discord" {
106        "discord"
107    } else if chat_type == "web" {
108        "web"
109    } else {
110        "unknown"
111    }
112}
113
114pub(crate) fn ensure_chat_identity_schema(conn: &Connection) -> Result<(), MicroClawError> {
115    if !table_has_column(conn, "chats", "channel")? {
116        conn.execute("ALTER TABLE chats ADD COLUMN channel TEXT", [])?;
117    }
118    if !table_has_column(conn, "chats", "external_chat_id")? {
119        conn.execute("ALTER TABLE chats ADD COLUMN external_chat_id TEXT", [])?;
120    }
121
122    conn.execute(
123        "UPDATE chats
124         SET channel = CASE
125             WHEN chat_type LIKE 'telegram_%' THEN 'telegram'
126             WHEN chat_type IN ('private', 'group', 'supergroup', 'channel') THEN 'telegram'
127             WHEN chat_type = 'discord' THEN 'discord'
128             WHEN chat_type = 'web' THEN 'web'
129             ELSE COALESCE(channel, 'unknown')
130         END
131         WHERE channel IS NULL OR trim(channel) = ''",
132        [],
133    )?;
134    conn.execute(
135        "UPDATE chats
136         SET external_chat_id = CAST(chat_id AS TEXT)
137         WHERE external_chat_id IS NULL OR trim(external_chat_id) = ''",
138        [],
139    )?;
140    conn.execute(
141        "CREATE INDEX IF NOT EXISTS idx_chats_channel_external
142         ON chats(channel, external_chat_id)",
143        [],
144    )?;
145    conn.execute(
146        "CREATE INDEX IF NOT EXISTS idx_chats_channel_title
147         ON chats(channel, chat_title)",
148        [],
149    )?;
150    Ok(())
151}
152
153pub(crate) fn ensure_sessions_schema(conn: &Connection) -> Result<(), MicroClawError> {
154    if !table_has_column(conn, "sessions", "parent_session_key")? {
155        conn.execute(
156            "ALTER TABLE sessions ADD COLUMN parent_session_key TEXT",
157            [],
158        )?;
159    }
160    if !table_has_column(conn, "sessions", "fork_point")? {
161        conn.execute("ALTER TABLE sessions ADD COLUMN fork_point INTEGER", [])?;
162    }
163    if !table_has_column(conn, "sessions", "label")? {
164        conn.execute("ALTER TABLE sessions ADD COLUMN label TEXT", [])?;
165    }
166    if !table_has_column(conn, "sessions", "thinking_level")? {
167        conn.execute("ALTER TABLE sessions ADD COLUMN thinking_level TEXT", [])?;
168    }
169    if !table_has_column(conn, "sessions", "verbose_level")? {
170        conn.execute("ALTER TABLE sessions ADD COLUMN verbose_level TEXT", [])?;
171    }
172    if !table_has_column(conn, "sessions", "reasoning_level")? {
173        conn.execute("ALTER TABLE sessions ADD COLUMN reasoning_level TEXT", [])?;
174    }
175    if table_has_column(conn, "sessions", "parent_session_key")? {
176        conn.execute(
177            "CREATE INDEX IF NOT EXISTS idx_sessions_parent_session_key
178             ON sessions(parent_session_key)",
179            [],
180        )?;
181    }
182    Ok(())
183}
184
185pub(crate) fn ensure_scheduled_tasks_schema(conn: &Connection) -> Result<(), MicroClawError> {
186    if !table_has_column(conn, "scheduled_tasks", "exit_criteria")? {
187        conn.execute(
188            "ALTER TABLE scheduled_tasks ADD COLUMN exit_criteria TEXT",
189            [],
190        )?;
191    }
192    if !table_has_column(conn, "scheduled_tasks", "run_count")? {
193        conn.execute(
194            "ALTER TABLE scheduled_tasks ADD COLUMN run_count INTEGER NOT NULL DEFAULT 0",
195            [],
196        )?;
197    }
198    if !table_has_column(conn, "scheduled_tasks", "max_runs")? {
199        conn.execute(
200            "ALTER TABLE scheduled_tasks ADD COLUMN max_runs INTEGER",
201            [],
202        )?;
203    }
204    if !table_has_column(conn, "scheduled_tasks", "not_after")? {
205        conn.execute("ALTER TABLE scheduled_tasks ADD COLUMN not_after TEXT", [])?;
206    }
207    if !table_has_column(conn, "scheduled_tasks", "timezone")? {
208        conn.execute(
209            "ALTER TABLE scheduled_tasks ADD COLUMN timezone TEXT NOT NULL DEFAULT ''",
210            [],
211        )?;
212    }
213    Ok(())
214}
215
216pub(crate) fn get_schema_version(conn: &Connection) -> Result<i64, MicroClawError> {
217    conn.execute(
218        "CREATE TABLE IF NOT EXISTS db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
219        [],
220    )?;
221    let raw: Option<String> = conn
222        .query_row(
223            "SELECT value FROM db_meta WHERE key = 'schema_version'",
224            [],
225            |row| row.get(0),
226        )
227        .optional()?;
228    Ok(raw.and_then(|s| s.parse::<i64>().ok()).unwrap_or(0))
229}
230
231pub(crate) fn set_schema_version(conn: &Connection, version: i64) -> Result<(), MicroClawError> {
232    conn.execute(
233        "INSERT INTO db_meta(key, value) VALUES('schema_version', ?1)
234         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
235        params![version.to_string()],
236    )?;
237    conn.execute(
238        "CREATE TABLE IF NOT EXISTS schema_migrations (
239            version INTEGER PRIMARY KEY,
240            applied_at TEXT NOT NULL,
241            note TEXT
242        )",
243        [],
244    )?;
245    conn.execute(
246        "INSERT OR REPLACE INTO schema_migrations(version, applied_at, note)
247         VALUES(?1, ?2, ?3)",
248        params![version, chrono::Utc::now().to_rfc3339(), "applied"],
249    )?;
250    Ok(())
251}
252
253pub(crate) fn apply_schema_migrations(conn: &Connection) -> Result<(), MicroClawError> {
254    let mut version = get_schema_version(conn)?;
255    if version < 1 {
256        set_schema_version(conn, 1)?;
257        version = 1;
258    }
259    if version < 2 {
260        ensure_chat_identity_schema(conn)?;
261        ensure_memory_schema(conn)?;
262        conn.execute(
263            "CREATE INDEX IF NOT EXISTS idx_memories_active_updated ON memories(is_archived, updated_at)",
264            [],
265        )?;
266        conn.execute(
267            "CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence)",
268            [],
269        )?;
270        set_schema_version(conn, 2)?;
271        version = 2;
272    }
273    if version < 3 {
274        conn.execute_batch(
275            "CREATE TABLE IF NOT EXISTS memory_reflector_runs (
276                id INTEGER PRIMARY KEY AUTOINCREMENT,
277                chat_id INTEGER NOT NULL,
278                started_at TEXT NOT NULL,
279                finished_at TEXT NOT NULL,
280                extracted_count INTEGER NOT NULL DEFAULT 0,
281                inserted_count INTEGER NOT NULL DEFAULT 0,
282                updated_count INTEGER NOT NULL DEFAULT 0,
283                skipped_count INTEGER NOT NULL DEFAULT 0,
284                dedup_method TEXT NOT NULL,
285                parse_ok INTEGER NOT NULL DEFAULT 1,
286                error_text TEXT
287            );
288            CREATE INDEX IF NOT EXISTS idx_memory_reflector_runs_chat_started
289                ON memory_reflector_runs(chat_id, started_at);
290            CREATE TABLE IF NOT EXISTS memory_injection_logs (
291                id INTEGER PRIMARY KEY AUTOINCREMENT,
292                chat_id INTEGER NOT NULL,
293                created_at TEXT NOT NULL,
294                retrieval_method TEXT NOT NULL,
295                candidate_count INTEGER NOT NULL DEFAULT 0,
296                selected_count INTEGER NOT NULL DEFAULT 0,
297                omitted_count INTEGER NOT NULL DEFAULT 0,
298                tokens_est INTEGER NOT NULL DEFAULT 0
299            );
300            CREATE INDEX IF NOT EXISTS idx_memory_injection_logs_chat_created
301                ON memory_injection_logs(chat_id, created_at);",
302        )?;
303        set_schema_version(conn, 3)?;
304        version = 3;
305    }
306    if version < 4 {
307        conn.execute_batch(
308            "CREATE TABLE IF NOT EXISTS memory_supersede_edges (
309                id INTEGER PRIMARY KEY AUTOINCREMENT,
310                from_memory_id INTEGER NOT NULL,
311                to_memory_id INTEGER NOT NULL,
312                reason TEXT,
313                created_at TEXT NOT NULL
314            );
315            CREATE INDEX IF NOT EXISTS idx_memory_supersede_from
316                ON memory_supersede_edges(from_memory_id, created_at);
317            CREATE INDEX IF NOT EXISTS idx_memory_supersede_to
318                ON memory_supersede_edges(to_memory_id, created_at);",
319        )?;
320        set_schema_version(conn, 4)?;
321        version = 4;
322    }
323    if version < 5 {
324        conn.execute_batch(
325            "CREATE TABLE IF NOT EXISTS auth_passwords (
326                id INTEGER PRIMARY KEY CHECK(id = 1),
327                password_hash TEXT NOT NULL,
328                created_at TEXT NOT NULL,
329                updated_at TEXT NOT NULL
330            );
331            CREATE TABLE IF NOT EXISTS auth_sessions (
332                session_id TEXT PRIMARY KEY,
333                label TEXT,
334                created_at TEXT NOT NULL,
335                expires_at TEXT NOT NULL,
336                last_seen_at TEXT NOT NULL,
337                revoked_at TEXT
338            );
339            CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
340            CREATE TABLE IF NOT EXISTS api_keys (
341                id INTEGER PRIMARY KEY AUTOINCREMENT,
342                label TEXT NOT NULL,
343                key_hash TEXT NOT NULL UNIQUE,
344                prefix TEXT NOT NULL,
345                created_at TEXT NOT NULL,
346                revoked_at TEXT,
347                last_used_at TEXT,
348                expires_at TEXT,
349                rotated_from_key_id INTEGER
350            );
351            CREATE TABLE IF NOT EXISTS api_key_scopes (
352                api_key_id INTEGER NOT NULL,
353                scope TEXT NOT NULL,
354                PRIMARY KEY (api_key_id, scope)
355            );
356            CREATE INDEX IF NOT EXISTS idx_api_key_scopes_scope ON api_key_scopes(scope);",
357        )?;
358        set_schema_version(conn, 5)?;
359        version = 5;
360    }
361    if version < 6 {
362        ensure_sessions_schema(conn)?;
363        set_schema_version(conn, 6)?;
364        version = 6;
365    }
366    if version < 7 {
367        conn.execute_batch(
368            "CREATE TABLE IF NOT EXISTS metrics_history (
369                timestamp_ms INTEGER PRIMARY KEY,
370                llm_completions INTEGER NOT NULL DEFAULT 0,
371                llm_input_tokens INTEGER NOT NULL DEFAULT 0,
372                llm_output_tokens INTEGER NOT NULL DEFAULT 0,
373                http_requests INTEGER NOT NULL DEFAULT 0,
374                tool_executions INTEGER NOT NULL DEFAULT 0,
375                mcp_calls INTEGER NOT NULL DEFAULT 0,
376                mcp_rate_limited_rejections INTEGER NOT NULL DEFAULT 0,
377                mcp_bulkhead_rejections INTEGER NOT NULL DEFAULT 0,
378                mcp_circuit_open_rejections INTEGER NOT NULL DEFAULT 0,
379                active_sessions INTEGER NOT NULL DEFAULT 0
380            );
381            CREATE INDEX IF NOT EXISTS idx_metrics_history_ts ON metrics_history(timestamp_ms);",
382        )?;
383        set_schema_version(conn, 7)?;
384        version = 7;
385    }
386    if version < 8 {
387        if !table_has_column(conn, "api_keys", "expires_at")? {
388            conn.execute("ALTER TABLE api_keys ADD COLUMN expires_at TEXT", [])?;
389        }
390        if !table_has_column(conn, "api_keys", "rotated_from_key_id")? {
391            conn.execute(
392                "ALTER TABLE api_keys ADD COLUMN rotated_from_key_id INTEGER",
393                [],
394            )?;
395        }
396        conn.execute_batch(
397            "CREATE TABLE IF NOT EXISTS audit_logs (
398                id INTEGER PRIMARY KEY AUTOINCREMENT,
399                kind TEXT NOT NULL,
400                actor TEXT NOT NULL,
401                action TEXT NOT NULL,
402                target TEXT,
403                status TEXT NOT NULL,
404                detail TEXT,
405                created_at TEXT NOT NULL
406            );
407            CREATE INDEX IF NOT EXISTS idx_audit_logs_kind_created
408                ON audit_logs(kind, created_at DESC);",
409        )?;
410        set_schema_version(conn, 8)?;
411        version = 8;
412    }
413    if version < 9 {
414        conn.execute_batch(
415            "CREATE TABLE IF NOT EXISTS scheduled_task_dlq (
416                id INTEGER PRIMARY KEY AUTOINCREMENT,
417                task_id INTEGER NOT NULL,
418                chat_id INTEGER NOT NULL,
419                failed_at TEXT NOT NULL,
420                started_at TEXT NOT NULL,
421                finished_at TEXT NOT NULL,
422                duration_ms INTEGER NOT NULL,
423                error_summary TEXT,
424                replayed_at TEXT,
425                replay_note TEXT
426            );
427            CREATE INDEX IF NOT EXISTS idx_scheduled_task_dlq_task_failed
428                ON scheduled_task_dlq(task_id, failed_at DESC);
429            CREATE INDEX IF NOT EXISTS idx_scheduled_task_dlq_chat_failed
430                ON scheduled_task_dlq(chat_id, failed_at DESC);",
431        )?;
432        set_schema_version(conn, 9)?;
433        version = 9;
434    }
435    if version < 10 {
436        if !table_has_column(conn, "metrics_history", "mcp_rate_limited_rejections")? {
437            conn.execute(
438                "ALTER TABLE metrics_history ADD COLUMN mcp_rate_limited_rejections INTEGER NOT NULL DEFAULT 0",
439                [],
440            )?;
441        }
442        if !table_has_column(conn, "metrics_history", "mcp_bulkhead_rejections")? {
443            conn.execute(
444                "ALTER TABLE metrics_history ADD COLUMN mcp_bulkhead_rejections INTEGER NOT NULL DEFAULT 0",
445                [],
446            )?;
447        }
448        if !table_has_column(conn, "metrics_history", "mcp_circuit_open_rejections")? {
449            conn.execute(
450                "ALTER TABLE metrics_history ADD COLUMN mcp_circuit_open_rejections INTEGER NOT NULL DEFAULT 0",
451                [],
452            )?;
453        }
454        set_schema_version(conn, 10)?;
455        version = 10;
456    }
457    if version < 11 {
458        if !table_has_column(conn, "sessions", "skill_envs_json")? {
459            conn.execute("ALTER TABLE sessions ADD COLUMN skill_envs_json TEXT", [])?;
460        }
461        set_schema_version(conn, 11)?;
462        version = 11;
463    }
464    if version < 12 {
465        ensure_scheduled_tasks_schema(conn)?;
466        set_schema_version(conn, 12)?;
467        version = 12;
468    }
469    if version < 13 {
470        conn.execute_batch(
471            "CREATE TABLE IF NOT EXISTS subagent_runs (
472                run_id TEXT PRIMARY KEY,
473                parent_run_id TEXT,
474                depth INTEGER NOT NULL DEFAULT 1,
475                chat_id INTEGER NOT NULL,
476                caller_channel TEXT NOT NULL,
477                task TEXT NOT NULL,
478                context TEXT NOT NULL DEFAULT '',
479                status TEXT NOT NULL,
480                created_at TEXT NOT NULL,
481                started_at TEXT,
482                finished_at TEXT,
483                cancel_requested INTEGER NOT NULL DEFAULT 0,
484                error_text TEXT,
485                result_text TEXT,
486                input_tokens INTEGER NOT NULL DEFAULT 0,
487                output_tokens INTEGER NOT NULL DEFAULT 0,
488                total_tokens INTEGER NOT NULL DEFAULT 0,
489                provider TEXT NOT NULL DEFAULT '',
490                model TEXT NOT NULL DEFAULT ''
491            );
492            CREATE INDEX IF NOT EXISTS idx_subagent_runs_chat_created
493                ON subagent_runs(chat_id, created_at DESC);
494            CREATE INDEX IF NOT EXISTS idx_subagent_runs_chat_status
495                ON subagent_runs(chat_id, status);
496            CREATE INDEX IF NOT EXISTS idx_subagent_runs_parent_status
497                ON subagent_runs(parent_run_id, status);",
498        )?;
499        set_schema_version(conn, 13)?;
500        version = 13;
501    }
502    if version < 14 {
503        if !table_has_column(conn, "subagent_runs", "parent_run_id")? {
504            conn.execute(
505                "ALTER TABLE subagent_runs ADD COLUMN parent_run_id TEXT",
506                [],
507            )?;
508        }
509        if !table_has_column(conn, "subagent_runs", "depth")? {
510            conn.execute(
511                "ALTER TABLE subagent_runs ADD COLUMN depth INTEGER NOT NULL DEFAULT 1",
512                [],
513            )?;
514        }
515        conn.execute(
516            "CREATE INDEX IF NOT EXISTS idx_subagent_runs_parent_status
517             ON subagent_runs(parent_run_id, status)",
518            [],
519        )?;
520        set_schema_version(conn, 14)?;
521        version = 14;
522    }
523    if version < 15 {
524        conn.execute_batch(
525            "CREATE TABLE IF NOT EXISTS subagent_announces (
526                id INTEGER PRIMARY KEY AUTOINCREMENT,
527                run_id TEXT NOT NULL UNIQUE,
528                chat_id INTEGER NOT NULL,
529                caller_channel TEXT NOT NULL,
530                payload_text TEXT NOT NULL,
531                status TEXT NOT NULL DEFAULT 'pending',
532                attempts INTEGER NOT NULL DEFAULT 0,
533                next_attempt_at TEXT,
534                last_error TEXT,
535                created_at TEXT NOT NULL,
536                updated_at TEXT NOT NULL
537            );
538            CREATE INDEX IF NOT EXISTS idx_subagent_announces_status_next
539                ON subagent_announces(status, next_attempt_at);",
540        )?;
541        set_schema_version(conn, 15)?;
542        version = 15;
543    }
544    if version < 16 {
545        conn.execute_batch(
546            "CREATE TABLE IF NOT EXISTS subagent_events (
547                id INTEGER PRIMARY KEY AUTOINCREMENT,
548                run_id TEXT NOT NULL,
549                event_type TEXT NOT NULL,
550                detail TEXT,
551                created_at TEXT NOT NULL
552            );
553            CREATE INDEX IF NOT EXISTS idx_subagent_events_run_created
554                ON subagent_events(run_id, created_at ASC);",
555        )?;
556        set_schema_version(conn, 16)?;
557        version = 16;
558    }
559    if version < 17 {
560        conn.execute_batch(
561            "CREATE TABLE IF NOT EXISTS subagent_focus_bindings (
562                chat_id INTEGER PRIMARY KEY,
563                run_id TEXT NOT NULL,
564                updated_at TEXT NOT NULL
565            );",
566        )?;
567        set_schema_version(conn, 17)?;
568        version = 17;
569    }
570    if version < 18 {
571        if !table_has_column(conn, "subagent_runs", "token_budget")? {
572            conn.execute(
573                "ALTER TABLE subagent_runs ADD COLUMN token_budget INTEGER NOT NULL DEFAULT 0",
574                [],
575            )?;
576        }
577        if !table_has_column(conn, "subagent_runs", "artifact_json")? {
578            conn.execute(
579                "ALTER TABLE subagent_runs ADD COLUMN artifact_json TEXT",
580                [],
581            )?;
582        }
583        set_schema_version(conn, 18)?;
584        version = 18;
585    }
586    if version < 19 {
587        ensure_sessions_schema(conn)?;
588        set_schema_version(conn, 19)?;
589        version = 19;
590    }
591    if version < 20 {
592        // Temporal knowledge graph: add valid_from/valid_to to memories + knowledge_graph table
593        if !table_has_column(conn, "memories", "valid_from")? {
594            conn.execute("ALTER TABLE memories ADD COLUMN valid_from TEXT", [])?;
595        }
596        if !table_has_column(conn, "memories", "valid_to")? {
597            conn.execute("ALTER TABLE memories ADD COLUMN valid_to TEXT", [])?;
598        }
599        conn.execute_batch(
600            "CREATE TABLE IF NOT EXISTS knowledge_graph (
601                id INTEGER PRIMARY KEY AUTOINCREMENT,
602                subject TEXT NOT NULL,
603                predicate TEXT NOT NULL,
604                object TEXT NOT NULL,
605                chat_id INTEGER,
606                valid_from TEXT NOT NULL,
607                valid_to TEXT,
608                confidence REAL NOT NULL DEFAULT 0.70,
609                source TEXT NOT NULL DEFAULT 'reflector',
610                source_memory_id INTEGER,
611                created_at TEXT NOT NULL
612            );
613            CREATE INDEX IF NOT EXISTS idx_kg_subject ON knowledge_graph(subject);
614            CREATE INDEX IF NOT EXISTS idx_kg_object ON knowledge_graph(object);
615            CREATE INDEX IF NOT EXISTS idx_kg_predicate ON knowledge_graph(predicate);
616            CREATE INDEX IF NOT EXISTS idx_kg_chat ON knowledge_graph(chat_id);
617            CREATE INDEX IF NOT EXISTS idx_kg_valid_range ON knowledge_graph(valid_from, valid_to);",
618        )?;
619        set_schema_version(conn, 20)?;
620        version = 20;
621    }
622    if version < 21 {
623        // Session search: FTS5 virtual table over messages, with triggers to
624        // keep it in sync on INSERT/UPDATE/DELETE. The table is created as
625        // contentless (`content=''`) to avoid duplicating text on disk; we
626        // manually keep it in sync via triggers rather than rely on the
627        // external-content mode so that deletions of individual messages are
628        // still cleanly reflected.
629        conn.execute_batch(
630            "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
631                content,
632                sender_name,
633                chat_id UNINDEXED,
634                message_id UNINDEXED,
635                timestamp UNINDEXED,
636                is_from_bot UNINDEXED,
637                tokenize = 'unicode61 remove_diacritics 2'
638            );",
639        )?;
640        // Backfill existing messages into the FTS index. rowid pattern uses
641        // a composite of chat_id and message_id to stay unique.
642        conn.execute_batch(
643            "INSERT INTO messages_fts(content, sender_name, chat_id, message_id, timestamp, is_from_bot)
644             SELECT content, sender_name, chat_id, id, timestamp, is_from_bot FROM messages;",
645        )?;
646        conn.execute_batch(
647            "CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
648                INSERT INTO messages_fts(content, sender_name, chat_id, message_id, timestamp, is_from_bot)
649                VALUES (new.content, new.sender_name, new.chat_id, new.id, new.timestamp, new.is_from_bot);
650            END;
651            CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
652                DELETE FROM messages_fts WHERE chat_id = old.chat_id AND message_id = old.id;
653            END;
654            CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN
655                DELETE FROM messages_fts WHERE chat_id = old.chat_id AND message_id = old.id;
656                INSERT INTO messages_fts(content, sender_name, chat_id, message_id, timestamp, is_from_bot)
657                VALUES (new.content, new.sender_name, new.chat_id, new.id, new.timestamp, new.is_from_bot);
658            END;",
659        )?;
660        set_schema_version(conn, 21)?;
661        version = 21;
662    }
663    if version < 22 {
664        // Tool result cache — keyed by SHA-256 of (tool_name + normalized
665        // input JSON). Tools opt in by name; rows are purged lazily via TTL.
666        conn.execute_batch(
667            "CREATE TABLE IF NOT EXISTS tool_result_cache (
668                cache_key TEXT PRIMARY KEY,
669                tool_name TEXT NOT NULL,
670                result_content TEXT NOT NULL,
671                is_error INTEGER NOT NULL DEFAULT 0,
672                metadata_json TEXT,
673                created_at TEXT NOT NULL,
674                expires_at TEXT NOT NULL
675            );
676            CREATE INDEX IF NOT EXISTS idx_tool_result_cache_tool_expires
677                ON tool_result_cache(tool_name, expires_at);
678            CREATE INDEX IF NOT EXISTS idx_tool_result_cache_expires
679                ON tool_result_cache(expires_at);",
680        )?;
681        set_schema_version(conn, 22)?;
682        version = 22;
683    }
684    if version < 23 {
685        // Tool result artifacts — full content stash for results that exceed
686        // the in-context truncation threshold. The agent reads slices via
687        // the `fetch_artifact` tool. Rows expire after a TTL to bound
688        // storage growth.
689        conn.execute_batch(
690            "CREATE TABLE IF NOT EXISTS tool_result_artifacts (
691                artifact_id TEXT PRIMARY KEY,
692                chat_id INTEGER NOT NULL,
693                tool_name TEXT NOT NULL,
694                content TEXT NOT NULL,
695                total_chars INTEGER NOT NULL,
696                created_at TEXT NOT NULL,
697                expires_at TEXT NOT NULL
698            );
699            CREATE INDEX IF NOT EXISTS idx_tool_result_artifacts_chat
700                ON tool_result_artifacts(chat_id, expires_at);
701            CREATE INDEX IF NOT EXISTS idx_tool_result_artifacts_expires
702                ON tool_result_artifacts(expires_at);",
703        )?;
704        set_schema_version(conn, 23)?;
705        version = 23;
706    }
707    if version < 24 {
708        // Memory TTL: per-row expiration for time-bounded facts (NULL = never).
709        // Distinct from `valid_to` (knowledge-graph temporal validity) and
710        // `is_archived` (manual demotion).
711        if !table_has_column(conn, "memories", "expires_at")? {
712            conn.execute("ALTER TABLE memories ADD COLUMN expires_at TEXT", [])?;
713        }
714        conn.execute(
715            "CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at)
716             WHERE expires_at IS NOT NULL",
717            [],
718        )?;
719        set_schema_version(conn, 24)?;
720        version = 24;
721    }
722    if version < 25 {
723        // Skill activation log — drives the auto-archive of agent-created
724        // skills that haven't been used in N days, and surfaces usage
725        // counts in the insights tool.
726        conn.execute_batch(
727            "CREATE TABLE IF NOT EXISTS skill_activation_logs (
728                id INTEGER PRIMARY KEY AUTOINCREMENT,
729                skill_name TEXT NOT NULL,
730                chat_id INTEGER,
731                activated_at TEXT NOT NULL
732            );
733            CREATE INDEX IF NOT EXISTS idx_skill_activation_name_time
734                ON skill_activation_logs(skill_name, activated_at DESC);
735            CREATE INDEX IF NOT EXISTS idx_skill_activation_time
736                ON skill_activation_logs(activated_at);",
737        )?;
738        set_schema_version(conn, 25)?;
739        version = 25;
740    }
741    if version < 26 {
742        // Named, progress-reporting sub-agent runs: a human-friendly `label`
743        // for "what am I working on", plus the latest progress snapshot pushed
744        // by the `report_progress` tool during a long run.
745        if !table_has_column(conn, "subagent_runs", "label")? {
746            conn.execute("ALTER TABLE subagent_runs ADD COLUMN label TEXT", [])?;
747        }
748        if !table_has_column(conn, "subagent_runs", "progress_text")? {
749            conn.execute(
750                "ALTER TABLE subagent_runs ADD COLUMN progress_text TEXT",
751                [],
752            )?;
753        }
754        if !table_has_column(conn, "subagent_runs", "last_progress_at")? {
755            conn.execute(
756                "ALTER TABLE subagent_runs ADD COLUMN last_progress_at TEXT",
757                [],
758            )?;
759        }
760        set_schema_version(conn, 26)?;
761        version = 26;
762    }
763    if version < 27 {
764        // Tamper-evident audit log: each new entry is sealed into a SHA-256 hash
765        // chain (`entry_hash` over the entry's fields plus the previous entry's
766        // `entry_hash`). Existing pre-migration rows stay unsealed (NULL) and are
767        // simply not part of the verifiable chain.
768        if !table_has_column(conn, "audit_logs", "prev_hash")? {
769            conn.execute("ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT", [])?;
770        }
771        if !table_has_column(conn, "audit_logs", "entry_hash")? {
772            conn.execute("ALTER TABLE audit_logs ADD COLUMN entry_hash TEXT", [])?;
773        }
774        set_schema_version(conn, 27)?;
775        version = 27;
776    }
777    if version < 28 {
778        // Interrupted-turn recovery: one row per interactive turn in flight.
779        // Inserted when the agent loop starts a user-facing turn, deleted when
780        // the turn finishes (any outcome). Rows found at startup mean the
781        // process died mid-reply — those chats get an "I was interrupted"
782        // notice and the rows are cleared.
783        conn.execute_batch(
784            "CREATE TABLE IF NOT EXISTS active_turns (
785                chat_id INTEGER PRIMARY KEY,
786                channel TEXT NOT NULL,
787                started_at TEXT NOT NULL
788            );",
789        )?;
790        set_schema_version(conn, 28)?;
791        version = 28;
792    }
793    if version < 29 {
794        // Delivery outbox: final agent replies that failed to send are queued
795        // here and retried with backoff by a supervised background loop, so a
796        // transient channel outage can't silently drop a finished answer.
797        // Also: `active_turns.progress_text` — a rolling "step N: tool, tool"
798        // snapshot so the interrupted-turn notice can say how far the run got.
799        conn.execute_batch(
800            "CREATE TABLE IF NOT EXISTS outbox_messages (
801                id INTEGER PRIMARY KEY AUTOINCREMENT,
802                chat_id INTEGER NOT NULL,
803                channel TEXT NOT NULL,
804                payload_text TEXT NOT NULL,
805                status TEXT NOT NULL DEFAULT 'pending',
806                attempts INTEGER NOT NULL DEFAULT 0,
807                next_attempt_at TEXT,
808                last_error TEXT,
809                created_at TEXT NOT NULL,
810                updated_at TEXT NOT NULL
811            );
812            CREATE INDEX IF NOT EXISTS idx_outbox_status_next
813                ON outbox_messages(status, next_attempt_at);",
814        )?;
815        if !table_has_column(conn, "active_turns", "progress_text")? {
816            conn.execute("ALTER TABLE active_turns ADD COLUMN progress_text TEXT", [])?;
817        }
818        set_schema_version(conn, 29)?;
819        version = 29;
820    }
821    if version < 30 {
822        // Durable chunk delivery ledger. The parent row represents one
823        // user-visible message; child rows are independently retryable chunks
824        // with stable idempotency keys. Legacy whole-message outbox rows are
825        // migrated as single-chunk deliveries.
826        conn.execute_batch(
827            "CREATE TABLE IF NOT EXISTS outbound_deliveries (
828                delivery_id TEXT PRIMARY KEY,
829                chat_id INTEGER NOT NULL,
830                channel TEXT NOT NULL,
831                full_payload_text TEXT NOT NULL,
832                status TEXT NOT NULL DEFAULT 'pending',
833                stored_at TEXT,
834                created_at TEXT NOT NULL,
835                updated_at TEXT NOT NULL
836            );
837            CREATE TABLE IF NOT EXISTS outbound_delivery_chunks (
838                id INTEGER PRIMARY KEY AUTOINCREMENT,
839                delivery_id TEXT NOT NULL,
840                chunk_index INTEGER NOT NULL,
841                total_chunks INTEGER NOT NULL,
842                payload_text TEXT NOT NULL,
843                idempotency_key TEXT NOT NULL,
844                status TEXT NOT NULL DEFAULT 'pending',
845                attempts INTEGER NOT NULL DEFAULT 0,
846                next_attempt_at TEXT,
847                last_error TEXT,
848                created_at TEXT NOT NULL,
849                updated_at TEXT NOT NULL,
850                UNIQUE(delivery_id, chunk_index),
851                UNIQUE(idempotency_key),
852                FOREIGN KEY(delivery_id) REFERENCES outbound_deliveries(delivery_id)
853            );
854            CREATE INDEX IF NOT EXISTS idx_delivery_chunks_status_next
855                ON outbound_delivery_chunks(status, next_attempt_at);
856            INSERT OR IGNORE INTO outbound_deliveries(
857                delivery_id, chat_id, channel, full_payload_text, status,
858                stored_at, created_at, updated_at
859            )
860            SELECT 'legacy-' || id, chat_id, channel, payload_text,
861                   CASE WHEN status = 'delivered' THEN 'delivered'
862                        WHEN status = 'failed' THEN 'failed' ELSE 'pending' END,
863                   CASE WHEN status = 'delivered' THEN updated_at ELSE NULL END,
864                   created_at, updated_at
865            FROM outbox_messages;
866            INSERT OR IGNORE INTO outbound_delivery_chunks(
867                delivery_id, chunk_index, total_chunks, payload_text,
868                idempotency_key, status, attempts, next_attempt_at,
869                last_error, created_at, updated_at
870            )
871            SELECT 'legacy-' || id, 0, 1, payload_text, 'legacy-' || id,
872                   status, attempts, next_attempt_at, last_error,
873                   created_at, updated_at
874            FROM outbox_messages;",
875        )?;
876        set_schema_version(conn, 30)?;
877        version = 30;
878    }
879    if version < 31 {
880        // Durable interactive-run checkpoints. The session snapshot is only
881        // marked resumable at boundaries where no tool side effect is in an
882        // unknown state. During tool execution the row remains inspectable,
883        // but startup recovery stops with evidence instead of replaying it.
884        if !table_has_column(conn, "active_turns", "chat_type")? {
885            conn.execute(
886                "ALTER TABLE active_turns ADD COLUMN chat_type TEXT NOT NULL DEFAULT 'private'",
887                [],
888            )?;
889        }
890        if !table_has_column(conn, "active_turns", "phase")? {
891            conn.execute(
892                "ALTER TABLE active_turns ADD COLUMN phase TEXT NOT NULL DEFAULT 'starting'",
893                [],
894            )?;
895        }
896        if !table_has_column(conn, "active_turns", "iteration")? {
897            conn.execute(
898                "ALTER TABLE active_turns ADD COLUMN iteration INTEGER NOT NULL DEFAULT 0",
899                [],
900            )?;
901        }
902        if !table_has_column(conn, "active_turns", "session_json")? {
903            conn.execute("ALTER TABLE active_turns ADD COLUMN session_json TEXT", [])?;
904        }
905        if !table_has_column(conn, "active_turns", "resumable")? {
906            conn.execute(
907                "ALTER TABLE active_turns ADD COLUMN resumable INTEGER NOT NULL DEFAULT 0",
908                [],
909            )?;
910        }
911        if !table_has_column(conn, "active_turns", "tool_summary")? {
912            conn.execute("ALTER TABLE active_turns ADD COLUMN tool_summary TEXT", [])?;
913        }
914        if !table_has_column(conn, "active_turns", "last_checkpoint_at")? {
915            conn.execute(
916                "ALTER TABLE active_turns ADD COLUMN last_checkpoint_at TEXT",
917                [],
918            )?;
919        }
920        if !table_has_column(conn, "active_turns", "run_id")? {
921            conn.execute("ALTER TABLE active_turns ADD COLUMN run_id TEXT", [])?;
922        }
923        conn.execute(
924            "CREATE INDEX IF NOT EXISTS idx_active_turns_checkpoint
925             ON active_turns(resumable, last_checkpoint_at)",
926            [],
927        )?;
928        set_schema_version(conn, 31)?;
929        version = 31;
930    }
931    if version < 32 {
932        // Long-horizon learning substrate. Facts remain in `memories`; these
933        // tables track goals, task experience, verification evidence, and the
934        // governed lifecycle of procedural skills.
935        conn.execute_batch(
936            "CREATE TABLE IF NOT EXISTS goal_states (
937                goal_id TEXT PRIMARY KEY,
938                chat_id INTEGER NOT NULL,
939                objective TEXT NOT NULL,
940                status TEXT NOT NULL DEFAULT 'active',
941                constraints_json TEXT,
942                progress_json TEXT,
943                budget_json TEXT,
944                created_at TEXT NOT NULL,
945                updated_at TEXT NOT NULL,
946                completed_at TEXT
947            );
948            CREATE INDEX IF NOT EXISTS idx_goal_states_chat_status
949                ON goal_states(chat_id, status, updated_at DESC);
950
951            CREATE TABLE IF NOT EXISTS experience_runs (
952                run_id TEXT PRIMARY KEY,
953                goal_id TEXT,
954                chat_id INTEGER NOT NULL,
955                channel TEXT NOT NULL,
956                run_kind TEXT NOT NULL,
957                objective TEXT NOT NULL,
958                environment_fingerprint TEXT,
959                status TEXT NOT NULL DEFAULT 'running',
960                result_summary TEXT,
961                started_at TEXT NOT NULL,
962                finished_at TEXT,
963                duration_ms INTEGER,
964                FOREIGN KEY(goal_id) REFERENCES goal_states(goal_id)
965            );
966            CREATE INDEX IF NOT EXISTS idx_experience_runs_chat_started
967                ON experience_runs(chat_id, started_at DESC);
968            CREATE INDEX IF NOT EXISTS idx_experience_runs_goal_started
969                ON experience_runs(goal_id, started_at DESC);
970            CREATE INDEX IF NOT EXISTS idx_experience_runs_status
971                ON experience_runs(status, started_at DESC);
972
973            CREATE TABLE IF NOT EXISTS verifier_results (
974                id INTEGER PRIMARY KEY AUTOINCREMENT,
975                run_id TEXT NOT NULL,
976                verifier_type TEXT NOT NULL,
977                verifier_name TEXT NOT NULL,
978                verdict TEXT NOT NULL,
979                confidence REAL NOT NULL,
980                evidence TEXT,
981                scope TEXT,
982                verified_at TEXT NOT NULL,
983                valid_until TEXT,
984                UNIQUE(run_id, verifier_type, verifier_name),
985                FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
986            );
987            CREATE INDEX IF NOT EXISTS idx_verifier_results_run
988                ON verifier_results(run_id, verified_at DESC);
989
990            CREATE TABLE IF NOT EXISTS skill_versions (
991                id INTEGER PRIMARY KEY AUTOINCREMENT,
992                skill_name TEXT NOT NULL,
993                version INTEGER NOT NULL,
994                content TEXT NOT NULL,
995                content_hash TEXT NOT NULL,
996                source TEXT NOT NULL,
997                created_at TEXT NOT NULL,
998                UNIQUE(skill_name, version)
999            );
1000            CREATE INDEX IF NOT EXISTS idx_skill_versions_name_version
1001                ON skill_versions(skill_name, version DESC);
1002
1003            CREATE TABLE IF NOT EXISTS skill_lifecycle (
1004                skill_name TEXT PRIMARY KEY,
1005                state TEXT NOT NULL,
1006                active_version INTEGER NOT NULL,
1007                previous_trusted_version INTEGER,
1008                source TEXT NOT NULL,
1009                created_at TEXT NOT NULL,
1010                updated_at TEXT NOT NULL,
1011                state_reason TEXT
1012            );
1013            CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_state
1014                ON skill_lifecycle(state, updated_at DESC);
1015
1016            CREATE TABLE IF NOT EXISTS skill_lifecycle_events (
1017                id INTEGER PRIMARY KEY AUTOINCREMENT,
1018                skill_name TEXT NOT NULL,
1019                from_state TEXT,
1020                to_state TEXT NOT NULL,
1021                version INTEGER NOT NULL,
1022                reason TEXT NOT NULL,
1023                created_at TEXT NOT NULL
1024            );
1025            CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_events_name
1026                ON skill_lifecycle_events(skill_name, created_at DESC);
1027
1028            CREATE TABLE IF NOT EXISTS skill_outcomes (
1029                id INTEGER PRIMARY KEY AUTOINCREMENT,
1030                skill_name TEXT NOT NULL,
1031                skill_version INTEGER NOT NULL,
1032                run_id TEXT NOT NULL,
1033                verdict TEXT NOT NULL,
1034                verifier_type TEXT NOT NULL,
1035                confidence REAL NOT NULL,
1036                evidence TEXT,
1037                created_at TEXT NOT NULL,
1038                updated_at TEXT NOT NULL,
1039                UNIQUE(skill_name, run_id),
1040                FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1041            );
1042            CREATE INDEX IF NOT EXISTS idx_skill_outcomes_name_created
1043                ON skill_outcomes(skill_name, created_at DESC);",
1044        )?;
1045        if !table_has_column(conn, "skill_activation_logs", "experience_run_id")? {
1046            conn.execute(
1047                "ALTER TABLE skill_activation_logs ADD COLUMN experience_run_id TEXT",
1048                [],
1049            )?;
1050        }
1051        if !table_has_column(conn, "skill_activation_logs", "skill_version")? {
1052            conn.execute(
1053                "ALTER TABLE skill_activation_logs ADD COLUMN skill_version INTEGER",
1054                [],
1055            )?;
1056        }
1057        conn.execute(
1058            "CREATE INDEX IF NOT EXISTS idx_skill_activation_experience
1059             ON skill_activation_logs(experience_run_id)",
1060            [],
1061        )?;
1062        set_schema_version(conn, 32)?;
1063        version = 32;
1064    }
1065    if version < 33 {
1066        for (column, definition) in [
1067            ("input_tokens", "INTEGER NOT NULL DEFAULT 0"),
1068            ("output_tokens", "INTEGER NOT NULL DEFAULT 0"),
1069            ("llm_requests", "INTEGER NOT NULL DEFAULT 0"),
1070            ("tool_calls", "INTEGER NOT NULL DEFAULT 0"),
1071            ("tool_errors", "INTEGER NOT NULL DEFAULT 0"),
1072            ("estimated_cost_usd", "REAL"),
1073        ] {
1074            if !table_has_column(conn, "experience_runs", column)? {
1075                conn.execute(
1076                    &format!("ALTER TABLE experience_runs ADD COLUMN {column} {definition}"),
1077                    [],
1078                )?;
1079            }
1080        }
1081        set_schema_version(conn, 33)?;
1082        version = 33;
1083    }
1084    if version < 34 {
1085        if !table_has_column(conn, "skill_outcomes", "verifier_name")? {
1086            conn.execute(
1087                "ALTER TABLE skill_outcomes ADD COLUMN verifier_name TEXT",
1088                [],
1089            )?;
1090        }
1091        if !table_has_column(conn, "skill_outcomes", "valid_until")? {
1092            conn.execute("ALTER TABLE skill_outcomes ADD COLUMN valid_until TEXT", [])?;
1093        }
1094        set_schema_version(conn, 34)?;
1095        version = 34;
1096    }
1097    if version < 35 {
1098        if !table_has_column(conn, "skill_outcomes", "attribution_confidence")? {
1099            conn.execute(
1100                "ALTER TABLE skill_outcomes
1101                 ADD COLUMN attribution_confidence REAL NOT NULL DEFAULT 1.0",
1102                [],
1103            )?;
1104        }
1105        set_schema_version(conn, 35)?;
1106        version = 35;
1107    }
1108    if version < 36 {
1109        conn.execute_batch(
1110            "CREATE TABLE IF NOT EXISTS skill_governance_policy (
1111                singleton_id INTEGER PRIMARY KEY CHECK(singleton_id=1),
1112                candidate_failures_to_degrade INTEGER NOT NULL,
1113                trial_min_outcomes INTEGER NOT NULL,
1114                trial_promote_rate REAL NOT NULL,
1115                trial_degrade_rate REAL NOT NULL,
1116                trusted_min_outcomes INTEGER NOT NULL,
1117                trusted_degrade_rate REAL NOT NULL,
1118                updated_at TEXT NOT NULL
1119            );",
1120        )?;
1121        let policy = SkillGovernancePolicy::default();
1122        conn.execute(
1123            "INSERT OR IGNORE INTO skill_governance_policy(
1124                singleton_id, candidate_failures_to_degrade, trial_min_outcomes,
1125                trial_promote_rate, trial_degrade_rate, trusted_min_outcomes,
1126                trusted_degrade_rate, updated_at
1127             ) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1128            params![
1129                policy.candidate_failures_to_degrade,
1130                policy.trial_min_outcomes,
1131                policy.trial_promote_rate,
1132                policy.trial_degrade_rate,
1133                policy.trusted_min_outcomes,
1134                policy.trusted_degrade_rate,
1135                chrono::Utc::now().to_rfc3339()
1136            ],
1137        )?;
1138        set_schema_version(conn, 36)?;
1139        version = 36;
1140    }
1141    if version < 37 {
1142        conn.execute_batch(
1143            "CREATE TABLE IF NOT EXISTS outcome_envelopes (
1144                envelope_id TEXT PRIMARY KEY,
1145                schema_version INTEGER NOT NULL,
1146                run_id TEXT NOT NULL,
1147                source_kind TEXT NOT NULL,
1148                source_name TEXT NOT NULL,
1149                verdict TEXT NOT NULL,
1150                confidence REAL NOT NULL,
1151                evidence TEXT,
1152                scope TEXT,
1153                valid_until TEXT,
1154                payload_json TEXT NOT NULL DEFAULT '{}',
1155                created_at TEXT NOT NULL,
1156                FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1157            );
1158            CREATE INDEX IF NOT EXISTS idx_outcome_envelopes_run_created
1159                ON outcome_envelopes(run_id, created_at ASC);
1160
1161            CREATE TABLE IF NOT EXISTS experience_feedback (
1162                feedback_id TEXT NOT NULL,
1163                run_id TEXT NOT NULL,
1164                actor TEXT NOT NULL,
1165                verdict TEXT NOT NULL,
1166                confidence REAL NOT NULL,
1167                evidence TEXT,
1168                scope TEXT,
1169                created_at TEXT NOT NULL,
1170                updated_at TEXT NOT NULL,
1171                valid_until TEXT,
1172                PRIMARY KEY(run_id, actor, feedback_id),
1173                FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1174            );
1175            CREATE INDEX IF NOT EXISTS idx_experience_feedback_run_updated
1176                ON experience_feedback(run_id, updated_at DESC);
1177
1178            CREATE TABLE IF NOT EXISTS experience_retrieval_logs (
1179                querying_run_id TEXT NOT NULL,
1180                source_run_id TEXT NOT NULL,
1181                rank INTEGER NOT NULL,
1182                selection_reason TEXT NOT NULL,
1183                relevance_score REAL NOT NULL,
1184                injected_at TEXT NOT NULL,
1185                PRIMARY KEY(querying_run_id, source_run_id),
1186                FOREIGN KEY(querying_run_id) REFERENCES experience_runs(run_id),
1187                FOREIGN KEY(source_run_id) REFERENCES experience_runs(run_id)
1188            );
1189            CREATE INDEX IF NOT EXISTS idx_experience_retrieval_query_rank
1190                ON experience_retrieval_logs(querying_run_id, rank ASC);
1191
1192            INSERT OR IGNORE INTO outcome_envelopes(
1193                envelope_id, schema_version, run_id, source_kind, source_name,
1194                verdict, confidence, evidence, scope, valid_until, payload_json,
1195                created_at
1196            )
1197            SELECT 'legacy-verifier:' || id, 1, run_id, verifier_type,
1198                   verifier_name, verdict, confidence, evidence, scope,
1199                   valid_until, '{}', verified_at
1200            FROM verifier_results;
1201
1202            INSERT OR IGNORE INTO experience_feedback(
1203                feedback_id, run_id, actor, verdict, confidence, evidence,
1204                scope, created_at, updated_at, valid_until
1205            )
1206            SELECT verifier_name, run_id, 'legacy', verdict, confidence,
1207                   evidence, scope, verified_at, verified_at, valid_until
1208            FROM verifier_results
1209            WHERE verifier_type='human';",
1210        )?;
1211        set_schema_version(conn, 37)?;
1212        version = 37;
1213    }
1214    if version < 38 {
1215        for (column, definition) in [
1216            ("task_signature_version", "INTEGER NOT NULL DEFAULT 1"),
1217            ("task_type", "TEXT NOT NULL DEFAULT 'general'"),
1218            ("task_family", "TEXT NOT NULL DEFAULT 'general_assistance'"),
1219            ("capability_tags_json", "TEXT NOT NULL DEFAULT '[]'"),
1220            ("task_signature_hash", "TEXT NOT NULL DEFAULT ''"),
1221        ] {
1222            if !table_has_column(conn, "experience_runs", column)? {
1223                conn.execute(
1224                    &format!("ALTER TABLE experience_runs ADD COLUMN {column} {definition}"),
1225                    [],
1226                )?;
1227            }
1228        }
1229        if !table_has_column(conn, "skill_governance_policy", "utility_confidence_z")? {
1230            conn.execute(
1231                "ALTER TABLE skill_governance_policy
1232                 ADD COLUMN utility_confidence_z REAL NOT NULL DEFAULT 1.96",
1233                [],
1234            )?;
1235        }
1236        if !table_has_column(
1237            conn,
1238            "skill_governance_policy",
1239            "trial_promote_utility_lower_bound",
1240        )? {
1241            conn.execute(
1242                "ALTER TABLE skill_governance_policy
1243                 ADD COLUMN trial_promote_utility_lower_bound REAL NOT NULL DEFAULT 0.4",
1244                [],
1245            )?;
1246        }
1247        let mut stmt = conn.prepare(
1248            "SELECT run_id, objective, run_kind FROM experience_runs
1249             WHERE task_signature_hash=''",
1250        )?;
1251        let existing = stmt
1252            .query_map([], |row| {
1253                Ok((
1254                    row.get::<_, String>(0)?,
1255                    row.get::<_, String>(1)?,
1256                    row.get::<_, String>(2)?,
1257                ))
1258            })?
1259            .collect::<Result<Vec<_>, _>>()?;
1260        drop(stmt);
1261        for (run_id, objective, run_kind) in existing {
1262            let signature = derive_task_signature(&objective, &run_kind);
1263            conn.execute(
1264                "UPDATE experience_runs SET task_signature_version=?2,
1265                    task_type=?3, task_family=?4, capability_tags_json=?5,
1266                    task_signature_hash=?6 WHERE run_id=?1",
1267                params![
1268                    run_id,
1269                    signature.version,
1270                    signature.task_type,
1271                    signature.task_family,
1272                    serde_json::to_string(&signature.capability_tags)
1273                        .unwrap_or_else(|_| "[]".into()),
1274                    signature.signature_hash
1275                ],
1276            )?;
1277        }
1278        conn.execute(
1279            "CREATE INDEX IF NOT EXISTS idx_experience_runs_task_signature
1280             ON experience_runs(task_type, task_family, started_at DESC)",
1281            [],
1282        )?;
1283        set_schema_version(conn, 38)?;
1284        version = 38;
1285    }
1286    if version < 39 {
1287        conn.execute_batch(
1288            "CREATE TABLE IF NOT EXISTS skill_failure_patterns (
1289                pattern_id TEXT PRIMARY KEY,
1290                skill_name TEXT NOT NULL,
1291                skill_version INTEGER NOT NULL,
1292                task_type TEXT NOT NULL,
1293                task_family TEXT NOT NULL,
1294                environment_fingerprint TEXT,
1295                tool_name TEXT,
1296                error_category TEXT NOT NULL,
1297                failure_count INTEGER NOT NULL DEFAULT 0,
1298                recovery_successes INTEGER NOT NULL DEFAULT 0,
1299                state TEXT NOT NULL DEFAULT 'observed',
1300                cooldown_until TEXT,
1301                last_evidence TEXT,
1302                first_seen_at TEXT NOT NULL,
1303                updated_at TEXT NOT NULL,
1304                UNIQUE(skill_name, skill_version, task_family,
1305                       environment_fingerprint, tool_name, error_category)
1306            );
1307            CREATE INDEX IF NOT EXISTS idx_skill_failure_patterns_lookup
1308                ON skill_failure_patterns(
1309                    skill_name, skill_version, task_family, state, updated_at DESC
1310                );
1311            CREATE TABLE IF NOT EXISTS skill_failure_pattern_evidence (
1312                pattern_id TEXT NOT NULL,
1313                run_id TEXT NOT NULL,
1314                verdict TEXT NOT NULL,
1315                created_at TEXT NOT NULL,
1316                PRIMARY KEY(pattern_id, run_id),
1317                FOREIGN KEY(pattern_id) REFERENCES skill_failure_patterns(pattern_id),
1318                FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1319            );
1320
1321            CREATE TABLE IF NOT EXISTS experience_retrieval_rejections (
1322                querying_run_id TEXT NOT NULL,
1323                source_run_id TEXT NOT NULL,
1324                rejection_reason TEXT NOT NULL,
1325                relevance_score REAL NOT NULL,
1326                rejected_at TEXT NOT NULL,
1327                PRIMARY KEY(querying_run_id, source_run_id),
1328                FOREIGN KEY(querying_run_id) REFERENCES experience_runs(run_id),
1329                FOREIGN KEY(source_run_id) REFERENCES experience_runs(run_id)
1330            );
1331            CREATE INDEX IF NOT EXISTS idx_experience_rejection_query
1332                ON experience_retrieval_rejections(querying_run_id, rejected_at ASC);",
1333        )?;
1334        for (column, definition) in [
1335            ("failure_pattern_min_failures", "INTEGER NOT NULL DEFAULT 2"),
1336            (
1337                "failure_pattern_cooldown_hours",
1338                "INTEGER NOT NULL DEFAULT 24",
1339            ),
1340            (
1341                "failure_pattern_recovery_successes",
1342                "INTEGER NOT NULL DEFAULT 2",
1343            ),
1344        ] {
1345            if !table_has_column(conn, "skill_governance_policy", column)? {
1346                conn.execute(
1347                    &format!(
1348                        "ALTER TABLE skill_governance_policy ADD COLUMN {column} {definition}"
1349                    ),
1350                    [],
1351                )?;
1352            }
1353        }
1354        let policy = SkillGovernancePolicy::default();
1355        let historical = {
1356            let mut stmt = conn.prepare(
1357                "SELECT o.skill_name, o.skill_version, o.run_id,
1358                        r.task_type, r.task_family, r.environment_fingerprint,
1359                        json_extract(e.payload_json, '$.tool_name'),
1360                        COALESCE(e.evidence, o.evidence)
1361                 FROM skill_outcomes o
1362                 JOIN experience_runs r ON r.run_id=o.run_id
1363                 LEFT JOIN outcome_envelopes e ON e.run_id=o.run_id
1364                    AND e.verdict='failed' AND e.scope='tool_result'
1365                 WHERE o.verdict='failed'
1366                   AND o.attribution_confidence >= 0.999
1367                   AND o.verifier_type IN (
1368                     'deterministic','environmental','human','rule_based'
1369                   )
1370                   AND (o.valid_until IS NULL OR o.valid_until>?1)
1371                 ORDER BY o.created_at ASC",
1372            )?;
1373            let rows = stmt
1374                .query_map(params![chrono::Utc::now().to_rfc3339()], |row| {
1375                    Ok((
1376                        row.get::<_, String>(0)?,
1377                        row.get::<_, i64>(1)?,
1378                        row.get::<_, String>(2)?,
1379                        row.get::<_, String>(3)?,
1380                        row.get::<_, String>(4)?,
1381                        row.get::<_, Option<String>>(5)?,
1382                        row.get::<_, Option<String>>(6)?,
1383                        row.get::<_, Option<String>>(7)?,
1384                    ))
1385                })?
1386                .collect::<Result<Vec<_>, _>>()?;
1387            rows
1388        };
1389        let now = chrono::Utc::now();
1390        let cooldown_until =
1391            (now + chrono::Duration::hours(policy.failure_pattern_cooldown_hours)).to_rfc3339();
1392        for (
1393            skill_name,
1394            skill_version,
1395            run_id,
1396            task_type,
1397            task_family,
1398            environment,
1399            tool_name,
1400            evidence,
1401        ) in historical
1402        {
1403            let category = classify_failure_category(evidence.as_deref());
1404            let canonical = format!(
1405                "{skill_name}\n{skill_version}\n{task_family}\n{}\n{}\n{category}",
1406                environment.as_deref().unwrap_or(""),
1407                tool_name.as_deref().unwrap_or("")
1408            );
1409            use sha2::{Digest, Sha256};
1410            let pattern_id = to_hex(&Sha256::digest(canonical.as_bytes()));
1411            conn.execute(
1412                "INSERT INTO skill_failure_patterns(
1413                    pattern_id, skill_name, skill_version, task_type, task_family,
1414                    environment_fingerprint, tool_name, error_category,
1415                    failure_count, recovery_successes, state, cooldown_until,
1416                    last_evidence, first_seen_at, updated_at
1417                 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,0,0,'observed',NULL,?9,?10,?10)
1418                 ON CONFLICT(pattern_id) DO UPDATE SET
1419                    last_evidence=excluded.last_evidence,
1420                    updated_at=excluded.updated_at",
1421                params![
1422                    pattern_id,
1423                    skill_name,
1424                    skill_version,
1425                    task_type,
1426                    task_family,
1427                    environment,
1428                    tool_name,
1429                    category,
1430                    evidence,
1431                    now.to_rfc3339()
1432                ],
1433            )?;
1434            if conn.execute(
1435                "INSERT OR IGNORE INTO skill_failure_pattern_evidence(
1436                    pattern_id, run_id, verdict, created_at
1437                 ) VALUES (?1,?2,'failed',?3)",
1438                params![pattern_id, run_id, now.to_rfc3339()],
1439            )? > 0
1440            {
1441                conn.execute(
1442                    "UPDATE skill_failure_patterns
1443                     SET failure_count=failure_count+1,
1444                         state=CASE WHEN failure_count+1>=?2
1445                                    THEN 'active' ELSE 'observed' END,
1446                         cooldown_until=CASE WHEN failure_count+1>=?2
1447                                    THEN ?3 ELSE NULL END,
1448                         updated_at=?4 WHERE pattern_id=?1",
1449                    params![
1450                        pattern_id,
1451                        policy.failure_pattern_min_failures,
1452                        cooldown_until,
1453                        now.to_rfc3339()
1454                    ],
1455                )?;
1456            }
1457        }
1458        conn.execute(
1459            "UPDATE skill_lifecycle
1460             SET previous_trusted_version=CASE WHEN state='trusted'
1461                    THEN active_version ELSE previous_trusted_version END,
1462                 state='degraded',
1463                 state_reason='historical active failure pattern backfilled',
1464                 updated_at=?1
1465             WHERE state NOT IN ('degraded','archived') AND EXISTS(
1466               SELECT 1 FROM skill_failure_patterns p
1467               WHERE p.skill_name=skill_lifecycle.skill_name
1468                 AND p.skill_version=skill_lifecycle.active_version
1469                 AND p.state='active'
1470             )",
1471            params![now.to_rfc3339()],
1472        )?;
1473        set_schema_version(conn, 39)?;
1474        version = 39;
1475    }
1476    if version < 40 {
1477        conn.execute_batch(
1478            "CREATE TABLE IF NOT EXISTS experience_comparisons (
1479                comparison_id TEXT PRIMARY KEY,
1480                chat_id INTEGER NOT NULL,
1481                skill_name TEXT NOT NULL,
1482                skill_version INTEGER NOT NULL,
1483                task_type TEXT NOT NULL,
1484                task_family TEXT NOT NULL,
1485                environment_fingerprint TEXT,
1486                success_run_id TEXT NOT NULL,
1487                failure_run_id TEXT NOT NULL,
1488                minimal_difference TEXT NOT NULL,
1489                counterexample TEXT NOT NULL,
1490                created_at TEXT NOT NULL,
1491                UNIQUE(chat_id, skill_name, skill_version, task_family,
1492                       environment_fingerprint, success_run_id, failure_run_id),
1493                FOREIGN KEY(success_run_id) REFERENCES experience_runs(run_id),
1494                FOREIGN KEY(failure_run_id) REFERENCES experience_runs(run_id)
1495            );
1496            CREATE INDEX IF NOT EXISTS idx_experience_comparisons_skill
1497                ON experience_comparisons(skill_name, skill_version, created_at DESC);
1498
1499            CREATE TABLE IF NOT EXISTS learning_claims (
1500                claim_id TEXT PRIMARY KEY,
1501                comparison_id TEXT NOT NULL,
1502                skill_name TEXT NOT NULL,
1503                base_version INTEGER NOT NULL,
1504                claim_version INTEGER NOT NULL,
1505                statement TEXT NOT NULL,
1506                applicability_json TEXT NOT NULL,
1507                confidence REAL NOT NULL,
1508                evidence_json TEXT NOT NULL,
1509                counterexamples_json TEXT NOT NULL,
1510                status TEXT NOT NULL DEFAULT 'candidate',
1511                supersedes_claim_id TEXT,
1512                created_at TEXT NOT NULL,
1513                updated_at TEXT NOT NULL,
1514                UNIQUE(skill_name, claim_version),
1515                FOREIGN KEY(comparison_id) REFERENCES experience_comparisons(comparison_id),
1516                FOREIGN KEY(supersedes_claim_id) REFERENCES learning_claims(claim_id)
1517            );
1518            CREATE INDEX IF NOT EXISTS idx_learning_claims_skill_status
1519                ON learning_claims(skill_name, status, updated_at DESC);
1520
1521            CREATE TABLE IF NOT EXISTS skill_candidates (
1522                candidate_id TEXT PRIMARY KEY,
1523                claim_id TEXT NOT NULL UNIQUE,
1524                skill_name TEXT NOT NULL,
1525                base_version INTEGER NOT NULL,
1526                candidate_version INTEGER NOT NULL,
1527                content TEXT NOT NULL,
1528                content_hash TEXT NOT NULL,
1529                status TEXT NOT NULL DEFAULT 'candidate',
1530                created_at TEXT NOT NULL,
1531                updated_at TEXT NOT NULL,
1532                UNIQUE(skill_name, candidate_version),
1533                FOREIGN KEY(claim_id) REFERENCES learning_claims(claim_id)
1534            );
1535            CREATE INDEX IF NOT EXISTS idx_skill_candidates_status
1536                ON skill_candidates(status, updated_at DESC);
1537
1538            CREATE TABLE IF NOT EXISTS shadow_observations (
1539                candidate_id TEXT NOT NULL,
1540                pair_key TEXT NOT NULL,
1541                arm TEXT NOT NULL,
1542                run_id TEXT NOT NULL,
1543                verdict TEXT NOT NULL,
1544                cost_usd REAL NOT NULL DEFAULT 0,
1545                duration_ms INTEGER NOT NULL DEFAULT 0,
1546                evidence TEXT,
1547                created_at TEXT NOT NULL,
1548                PRIMARY KEY(candidate_id, pair_key, arm),
1549                FOREIGN KEY(candidate_id) REFERENCES skill_candidates(candidate_id),
1550                FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1551            );
1552            CREATE INDEX IF NOT EXISTS idx_shadow_observations_candidate
1553                ON shadow_observations(candidate_id, pair_key, arm);
1554
1555            CREATE TABLE IF NOT EXISTS shadow_evaluations (
1556                evaluation_id TEXT PRIMARY KEY,
1557                candidate_id TEXT NOT NULL UNIQUE,
1558                sample_count INTEGER NOT NULL,
1559                baseline_passed INTEGER NOT NULL,
1560                candidate_passed INTEGER NOT NULL,
1561                baseline_utility_lower_bound REAL NOT NULL,
1562                candidate_utility_lower_bound REAL NOT NULL,
1563                baseline_cost_usd REAL NOT NULL,
1564                candidate_cost_usd REAL NOT NULL,
1565                baseline_duration_ms INTEGER NOT NULL,
1566                candidate_duration_ms INTEGER NOT NULL,
1567                regression_count INTEGER NOT NULL,
1568                verdict TEXT NOT NULL,
1569                reason TEXT NOT NULL,
1570                updated_at TEXT NOT NULL,
1571                FOREIGN KEY(candidate_id) REFERENCES skill_candidates(candidate_id)
1572            );
1573
1574            CREATE TABLE IF NOT EXISTS learning_journal_events (
1575                id INTEGER PRIMARY KEY AUTOINCREMENT,
1576                event_type TEXT NOT NULL,
1577                entity_type TEXT NOT NULL,
1578                entity_id TEXT NOT NULL,
1579                summary TEXT NOT NULL,
1580                evidence_json TEXT NOT NULL DEFAULT '{}',
1581                undo_action TEXT,
1582                created_at TEXT NOT NULL
1583            );
1584            CREATE INDEX IF NOT EXISTS idx_learning_journal_created
1585                ON learning_journal_events(created_at DESC);",
1586        )?;
1587        for (column, definition) in [
1588            ("shadow_min_samples", "INTEGER NOT NULL DEFAULT 3"),
1589            (
1590                "shadow_promote_utility_margin",
1591                "REAL NOT NULL DEFAULT 0.05",
1592            ),
1593            ("shadow_max_cost_ratio", "REAL NOT NULL DEFAULT 1.2"),
1594            ("shadow_max_regressions", "INTEGER NOT NULL DEFAULT 0"),
1595        ] {
1596            if !table_has_column(conn, "skill_governance_policy", column)? {
1597                conn.execute(
1598                    &format!(
1599                        "ALTER TABLE skill_governance_policy ADD COLUMN {column} {definition}"
1600                    ),
1601                    [],
1602                )?;
1603            }
1604        }
1605        let tx = conn.unchecked_transaction()?;
1606        let run_ids = {
1607            let mut stmt = tx.prepare(
1608                "SELECT DISTINCT run_id FROM skill_outcomes
1609                 WHERE attribution_confidence>=0.999
1610                   AND verifier_type IN (
1611                     'deterministic','environmental','human','rule_based'
1612                   )
1613                 ORDER BY created_at ASC",
1614            )?;
1615            let rows = stmt
1616                .query_map([], |row| row.get::<_, String>(0))?
1617                .collect::<Result<Vec<_>, _>>()?;
1618            rows
1619        };
1620        for run_id in run_ids {
1621            refresh_comparative_reflections_for_run(&tx, &run_id)?;
1622        }
1623        tx.commit()?;
1624        set_schema_version(conn, 40)?;
1625        version = 40;
1626    }
1627    if version < 41 {
1628        // Repair databases whose recorded schema version advanced despite an
1629        // incomplete historical scheduled_tasks migration or restore.
1630        ensure_scheduled_tasks_schema(conn)?;
1631        set_schema_version(conn, 41)?;
1632        version = 41;
1633    }
1634    if version < 42 {
1635        conn.execute_batch(
1636            "CREATE TABLE IF NOT EXISTS learning_tracks (
1637                track_id TEXT PRIMARY KEY,
1638                chat_id INTEGER NOT NULL,
1639                name TEXT NOT NULL,
1640                objective TEXT NOT NULL,
1641                directions_json TEXT NOT NULL DEFAULT '[]',
1642                allowed_sources_json TEXT NOT NULL DEFAULT '[]',
1643                schedule TEXT NOT NULL,
1644                timezone TEXT NOT NULL,
1645                token_budget INTEGER NOT NULL DEFAULT 80000,
1646                max_sources INTEGER NOT NULL DEFAULT 20,
1647                promotion_mode TEXT NOT NULL DEFAULT 'propose',
1648                status TEXT NOT NULL DEFAULT 'active',
1649                next_run TEXT NOT NULL,
1650                last_run TEXT,
1651                created_at TEXT NOT NULL,
1652                updated_at TEXT NOT NULL,
1653                UNIQUE(chat_id, name)
1654            );
1655            CREATE INDEX IF NOT EXISTS idx_learning_tracks_due
1656                ON learning_tracks(status, next_run);
1657
1658            CREATE TABLE IF NOT EXISTS learning_epochs (
1659                epoch_id TEXT PRIMARY KEY,
1660                track_id TEXT NOT NULL,
1661                experience_run_id TEXT,
1662                status TEXT NOT NULL DEFAULT 'running',
1663                report TEXT,
1664                candidate_id TEXT,
1665                error TEXT,
1666                started_at TEXT NOT NULL,
1667                finished_at TEXT,
1668                FOREIGN KEY(track_id) REFERENCES learning_tracks(track_id)
1669            );
1670            CREATE INDEX IF NOT EXISTS idx_learning_epochs_track
1671                ON learning_epochs(track_id, started_at DESC);
1672
1673            CREATE TABLE IF NOT EXISTS learning_track_candidates (
1674                candidate_id TEXT PRIMARY KEY,
1675                epoch_id TEXT NOT NULL UNIQUE,
1676                skill_name TEXT NOT NULL,
1677                description TEXT NOT NULL,
1678                instructions TEXT NOT NULL,
1679                sources_json TEXT NOT NULL DEFAULT '[]',
1680                tests_json TEXT NOT NULL DEFAULT '[]',
1681                risk TEXT NOT NULL DEFAULT 'low',
1682                content_hash TEXT NOT NULL,
1683                status TEXT NOT NULL DEFAULT 'pending',
1684                created_at TEXT NOT NULL,
1685                updated_at TEXT NOT NULL,
1686                FOREIGN KEY(epoch_id) REFERENCES learning_epochs(epoch_id)
1687            );
1688            CREATE INDEX IF NOT EXISTS idx_learning_track_candidates_status
1689                ON learning_track_candidates(status, updated_at DESC);",
1690        )?;
1691        set_schema_version(conn, 42)?;
1692        version = 42;
1693    }
1694    if version < 43 {
1695        conn.execute_batch(
1696            "CREATE TABLE IF NOT EXISTS learning_candidate_evaluations (
1697                evaluation_id TEXT PRIMARY KEY,
1698                candidate_id TEXT NOT NULL UNIQUE,
1699                status TEXT NOT NULL DEFAULT 'running',
1700                sample_count INTEGER NOT NULL DEFAULT 0,
1701                baseline_passed INTEGER NOT NULL DEFAULT 0,
1702                candidate_passed INTEGER NOT NULL DEFAULT 0,
1703                regression_count INTEGER NOT NULL DEFAULT 0,
1704                baseline_tokens INTEGER NOT NULL DEFAULT 0,
1705                candidate_tokens INTEGER NOT NULL DEFAULT 0,
1706                baseline_duration_ms INTEGER NOT NULL DEFAULT 0,
1707                candidate_duration_ms INTEGER NOT NULL DEFAULT 0,
1708                reason TEXT,
1709                started_at TEXT NOT NULL,
1710                finished_at TEXT,
1711                FOREIGN KEY(candidate_id) REFERENCES learning_track_candidates(candidate_id)
1712            );
1713            CREATE INDEX IF NOT EXISTS idx_learning_candidate_evaluations_status
1714                ON learning_candidate_evaluations(status, started_at DESC);
1715
1716            CREATE TABLE IF NOT EXISTS learning_candidate_trials (
1717                trial_id TEXT PRIMARY KEY,
1718                evaluation_id TEXT NOT NULL,
1719                test_name TEXT NOT NULL,
1720                baseline_passed INTEGER NOT NULL,
1721                candidate_passed INTEGER NOT NULL,
1722                baseline_tokens INTEGER NOT NULL DEFAULT 0,
1723                candidate_tokens INTEGER NOT NULL DEFAULT 0,
1724                baseline_duration_ms INTEGER NOT NULL DEFAULT 0,
1725                candidate_duration_ms INTEGER NOT NULL DEFAULT 0,
1726                evidence_json TEXT NOT NULL DEFAULT '{}',
1727                created_at TEXT NOT NULL,
1728                FOREIGN KEY(evaluation_id) REFERENCES learning_candidate_evaluations(evaluation_id)
1729            );
1730            CREATE INDEX IF NOT EXISTS idx_learning_candidate_trials_evaluation
1731                ON learning_candidate_trials(evaluation_id, created_at);",
1732        )?;
1733        set_schema_version(conn, 43)?;
1734        version = 43;
1735    }
1736    if version != SCHEMA_VERSION_CURRENT {
1737        set_schema_version(conn, SCHEMA_VERSION_CURRENT)?;
1738    }
1739    Ok(())
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744    use super::*;
1745    use crate::internal::storage::db::test_support::*;
1746
1747    #[test]
1748    fn test_schema_version_is_tracked() {
1749        let (db, dir) = test_db();
1750        let conn = db.lock_conn();
1751        let version: String = conn
1752            .query_row(
1753                "SELECT value FROM db_meta WHERE key = 'schema_version'",
1754                [],
1755                |row| row.get(0),
1756            )
1757            .unwrap();
1758        assert_eq!(version, SCHEMA_VERSION_CURRENT.to_string());
1759        drop(conn);
1760        cleanup(&dir);
1761    }
1762
1763    #[test]
1764    fn test_legacy_schema_is_upgraded_to_current_version() {
1765        let dir =
1766            std::env::temp_dir().join(format!("microclaw_legacy_upgrade_{}", uuid::Uuid::new_v4()));
1767        std::fs::create_dir_all(&dir).unwrap();
1768        let db_path = dir.join("microclaw.db");
1769        let conn = Connection::open(&db_path).unwrap();
1770        conn.execute_batch(
1771            "PRAGMA journal_mode=WAL;
1772             CREATE TABLE chats (
1773                chat_id INTEGER PRIMARY KEY,
1774                chat_title TEXT,
1775                chat_type TEXT NOT NULL DEFAULT 'private',
1776                last_message_time TEXT NOT NULL
1777             );
1778             CREATE TABLE messages (
1779                id TEXT NOT NULL,
1780                chat_id INTEGER NOT NULL,
1781                sender_name TEXT NOT NULL,
1782                content TEXT NOT NULL,
1783                is_from_bot INTEGER NOT NULL DEFAULT 0,
1784                timestamp TEXT NOT NULL,
1785                PRIMARY KEY (id, chat_id)
1786             );
1787             CREATE TABLE scheduled_tasks (
1788                id INTEGER PRIMARY KEY AUTOINCREMENT,
1789                chat_id INTEGER NOT NULL,
1790                prompt TEXT NOT NULL,
1791                schedule_type TEXT NOT NULL DEFAULT 'cron',
1792                schedule_value TEXT NOT NULL,
1793                next_run TEXT NOT NULL,
1794                last_run TEXT,
1795                status TEXT NOT NULL DEFAULT 'active',
1796                created_at TEXT NOT NULL
1797             );
1798             CREATE TABLE sessions (
1799                chat_id INTEGER PRIMARY KEY,
1800                messages_json TEXT NOT NULL,
1801                updated_at TEXT NOT NULL
1802             );
1803             CREATE TABLE memories (
1804                id INTEGER PRIMARY KEY AUTOINCREMENT,
1805                chat_id INTEGER,
1806                content TEXT NOT NULL,
1807                category TEXT NOT NULL,
1808                created_at TEXT NOT NULL,
1809                updated_at TEXT NOT NULL
1810             );",
1811        )
1812        .unwrap();
1813        drop(conn);
1814
1815        let db = Database::new(dir.to_str().unwrap()).unwrap();
1816        let conn = db.lock_conn();
1817        let version: String = conn
1818            .query_row(
1819                "SELECT value FROM db_meta WHERE key = 'schema_version'",
1820                [],
1821                |row| row.get(0),
1822            )
1823            .unwrap();
1824        assert_eq!(version, SCHEMA_VERSION_CURRENT.to_string());
1825
1826        let has_confidence = table_has_column(&conn, "memories", "confidence").unwrap();
1827        let has_source = table_has_column(&conn, "memories", "source").unwrap();
1828        let has_last_seen = table_has_column(&conn, "memories", "last_seen_at").unwrap();
1829        let has_archived = table_has_column(&conn, "memories", "is_archived").unwrap();
1830        assert!(has_confidence && has_source && has_last_seen && has_archived);
1831        assert!(table_has_column(&conn, "sessions", "parent_session_key").unwrap());
1832        assert!(table_has_column(&conn, "sessions", "fork_point").unwrap());
1833        assert!(table_has_column(&conn, "sessions", "label").unwrap());
1834        assert!(table_has_column(&conn, "sessions", "thinking_level").unwrap());
1835        assert!(table_has_column(&conn, "sessions", "verbose_level").unwrap());
1836        assert!(table_has_column(&conn, "sessions", "reasoning_level").unwrap());
1837        assert!(table_has_column(&conn, "scheduled_tasks", "timezone").unwrap());
1838
1839        let session_parent_index_exists: i64 = conn
1840            .query_row(
1841                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_sessions_parent_session_key'",
1842                [],
1843                |row| row.get(0),
1844            )
1845            .unwrap();
1846        assert_eq!(session_parent_index_exists, 1);
1847
1848        let supersede_table_exists: i64 = conn
1849            .query_row(
1850                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_supersede_edges'",
1851                [],
1852                |row| row.get(0),
1853            )
1854            .unwrap();
1855        assert_eq!(supersede_table_exists, 1);
1856        let dlq_table_exists: i64 = conn
1857            .query_row(
1858                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='scheduled_task_dlq'",
1859                [],
1860                |row| row.get(0),
1861            )
1862            .unwrap();
1863        assert_eq!(dlq_table_exists, 1);
1864        drop(conn);
1865        cleanup(&dir);
1866    }
1867
1868    #[test]
1869    fn test_migration_matrix_upgrades_multiple_legacy_versions() {
1870        fn seed_legacy_db(dir: &std::path::Path, version: i64) {
1871            let db_path = dir.join("microclaw.db");
1872            let conn = Connection::open(&db_path).unwrap();
1873            conn.execute_batch(
1874                "PRAGMA journal_mode=WAL;
1875                 CREATE TABLE chats (
1876                    chat_id INTEGER PRIMARY KEY,
1877                    chat_title TEXT,
1878                    chat_type TEXT NOT NULL DEFAULT 'private',
1879                    last_message_time TEXT NOT NULL
1880                 );
1881                 CREATE TABLE messages (
1882                    id TEXT NOT NULL,
1883                    chat_id INTEGER NOT NULL,
1884                    sender_name TEXT NOT NULL,
1885                    content TEXT NOT NULL,
1886                    is_from_bot INTEGER NOT NULL DEFAULT 0,
1887                    timestamp TEXT NOT NULL,
1888                    PRIMARY KEY (id, chat_id)
1889                 );
1890                 CREATE TABLE scheduled_tasks (
1891                    id INTEGER PRIMARY KEY AUTOINCREMENT,
1892                    chat_id INTEGER NOT NULL,
1893                    prompt TEXT NOT NULL,
1894                    schedule_type TEXT NOT NULL DEFAULT 'cron',
1895                    schedule_value TEXT NOT NULL,
1896                    next_run TEXT NOT NULL,
1897                    last_run TEXT,
1898                    status TEXT NOT NULL DEFAULT 'active',
1899                    created_at TEXT NOT NULL
1900                 );
1901                 CREATE TABLE sessions (
1902                    chat_id INTEGER PRIMARY KEY,
1903                    messages_json TEXT NOT NULL,
1904                    updated_at TEXT NOT NULL
1905                 );
1906                 CREATE TABLE memories (
1907                    id INTEGER PRIMARY KEY AUTOINCREMENT,
1908                    chat_id INTEGER,
1909                    content TEXT NOT NULL,
1910                    category TEXT NOT NULL,
1911                    created_at TEXT NOT NULL,
1912                    updated_at TEXT NOT NULL
1913                 );
1914                 CREATE TABLE IF NOT EXISTS db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
1915            )
1916            .unwrap();
1917            conn.execute(
1918                "INSERT OR REPLACE INTO db_meta(key, value) VALUES('schema_version', ?1)",
1919                params![version.to_string()],
1920            )
1921            .unwrap();
1922
1923            if version >= 5 {
1924                conn.execute_batch(
1925                    "CREATE TABLE IF NOT EXISTS api_keys (
1926                        id INTEGER PRIMARY KEY AUTOINCREMENT,
1927                        label TEXT NOT NULL,
1928                        key_hash TEXT NOT NULL UNIQUE,
1929                        prefix TEXT NOT NULL,
1930                        created_at TEXT NOT NULL,
1931                        revoked_at TEXT,
1932                        last_used_at TEXT
1933                    );",
1934                )
1935                .unwrap();
1936            }
1937            if version >= 7 {
1938                conn.execute_batch(
1939                    "CREATE TABLE IF NOT EXISTS metrics_history (
1940                        timestamp_ms INTEGER PRIMARY KEY,
1941                        llm_completions INTEGER NOT NULL DEFAULT 0,
1942                        llm_input_tokens INTEGER NOT NULL DEFAULT 0,
1943                        llm_output_tokens INTEGER NOT NULL DEFAULT 0,
1944                        http_requests INTEGER NOT NULL DEFAULT 0,
1945                        tool_executions INTEGER NOT NULL DEFAULT 0,
1946                        mcp_calls INTEGER NOT NULL DEFAULT 0,
1947                        active_sessions INTEGER NOT NULL DEFAULT 0
1948                    );",
1949                )
1950                .unwrap();
1951            }
1952            if version >= 8 {
1953                conn.execute_batch(
1954                    "ALTER TABLE api_keys ADD COLUMN expires_at TEXT;
1955                     ALTER TABLE api_keys ADD COLUMN rotated_from_key_id INTEGER;",
1956                )
1957                .unwrap();
1958            }
1959            drop(conn);
1960        }
1961
1962        for version in [1_i64, 5_i64, 7_i64, 8_i64] {
1963            let dir = std::env::temp_dir().join(format!(
1964                "microclaw_migration_matrix_{}_{}",
1965                version,
1966                uuid::Uuid::new_v4()
1967            ));
1968            std::fs::create_dir_all(&dir).unwrap();
1969            seed_legacy_db(&dir, version);
1970
1971            let db = Database::new(dir.to_str().unwrap()).unwrap();
1972            let conn = db.lock_conn();
1973            let actual: String = conn
1974                .query_row(
1975                    "SELECT value FROM db_meta WHERE key = 'schema_version'",
1976                    [],
1977                    |row| row.get(0),
1978                )
1979                .unwrap();
1980            assert_eq!(
1981                actual,
1982                SCHEMA_VERSION_CURRENT.to_string(),
1983                "legacy schema_version {} should migrate to current",
1984                version
1985            );
1986            assert!(table_has_column(&conn, "sessions", "parent_session_key").unwrap());
1987            assert!(table_has_column(&conn, "sessions", "fork_point").unwrap());
1988            assert!(table_has_column(&conn, "sessions", "label").unwrap());
1989            assert!(table_has_column(&conn, "sessions", "thinking_level").unwrap());
1990            assert!(table_has_column(&conn, "sessions", "verbose_level").unwrap());
1991            assert!(table_has_column(&conn, "sessions", "reasoning_level").unwrap());
1992            assert!(table_has_column(&conn, "scheduled_tasks", "timezone").unwrap());
1993            assert!(table_has_column(&conn, "api_keys", "expires_at").unwrap());
1994            assert!(table_has_column(&conn, "api_keys", "rotated_from_key_id").unwrap());
1995            assert!(
1996                table_has_column(&conn, "metrics_history", "mcp_rate_limited_rejections").unwrap()
1997            );
1998            assert!(table_has_column(&conn, "metrics_history", "mcp_bulkhead_rejections").unwrap());
1999            assert!(
2000                table_has_column(&conn, "metrics_history", "mcp_circuit_open_rejections").unwrap()
2001            );
2002            drop(conn);
2003            cleanup(&dir);
2004        }
2005    }
2006
2007    #[test]
2008    fn test_current_version_repairs_incomplete_scheduled_tasks_schema() {
2009        let dir = std::env::temp_dir().join(format!(
2010            "microclaw_incomplete_scheduled_tasks_{}",
2011            uuid::Uuid::new_v4()
2012        ));
2013        std::fs::create_dir_all(&dir).unwrap();
2014        let db_path = dir.join("microclaw.db");
2015        let conn = Connection::open(&db_path).unwrap();
2016        conn.execute_batch(
2017            "CREATE TABLE scheduled_tasks (
2018                id INTEGER PRIMARY KEY AUTOINCREMENT,
2019                chat_id INTEGER NOT NULL,
2020                prompt TEXT NOT NULL,
2021                schedule_type TEXT NOT NULL DEFAULT 'cron',
2022                schedule_value TEXT NOT NULL,
2023                next_run TEXT NOT NULL,
2024                last_run TEXT,
2025                status TEXT NOT NULL DEFAULT 'active',
2026                created_at TEXT NOT NULL
2027             );
2028             CREATE TABLE db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2029             INSERT INTO db_meta(key, value) VALUES('schema_version', '40');",
2030        )
2031        .unwrap();
2032        drop(conn);
2033
2034        let db = Database::new(dir.to_str().unwrap()).unwrap();
2035        let conn = db.lock_conn();
2036        for column in [
2037            "exit_criteria",
2038            "run_count",
2039            "max_runs",
2040            "not_after",
2041            "timezone",
2042        ] {
2043            assert!(
2044                table_has_column(&conn, "scheduled_tasks", column).unwrap(),
2045                "missing repaired scheduled_tasks.{column}"
2046            );
2047        }
2048        let version = get_schema_version(&conn).unwrap();
2049        assert_eq!(version, SCHEMA_VERSION_CURRENT);
2050        drop(conn);
2051
2052        // Exercise the exact query shape that failed in production.
2053        assert!(db.get_tasks_for_chat(1).unwrap().is_empty());
2054        cleanup(&dir);
2055    }
2056}