Skip to main content

khive_db/
migrations.rs

1//! Schema migration system for the SQLite storage layer.
2//!
3//! Two APIs coexist:
4//! - **Legacy per-service migrations** (`ServiceSchemaPlan` / `apply_schema_plan`):
5//!   used by pack-scoped schemas.
6//! - **Versioned migrations** (`MIGRATIONS` / `run_migrations`): the forward-only
7//!   migration pipeline for the core tables.
8//!
9//! Raw migration entry points require a connection opened by rusqlite;
10//! externally owned handles wrapped with `Connection::from_handle` are not
11//! supported. They reject inherited transactions. If rollback and close cannot
12//! establish an outcome, the original connection is retired before its volume
13//! lease is released and the caller receives `WriterSettlementUnknown`.
14
15use khive_storage::blob::ContentRef;
16use rusqlite::{Connection, OptionalExtension};
17use std::path::{Path, PathBuf};
18
19use crate::error::SqliteError;
20use crate::pool::WriteAdmission;
21use crate::stores::blob::{try_acquire_database_gc_owner_for_path, DatabaseGcOwnerGuard};
22
23#[path = "raw_migration_settlement.rs"]
24mod raw_migration_settlement;
25use raw_migration_settlement::{RawMigrationTransactions, RawMigrationWriteUnit};
26
27/// The write side of a migration run.
28///
29/// ADR-154 section 4 admits each migration transaction on its own: the volume
30/// lease is taken before the transaction begins and released once it has
31/// settled, so a multi-version upgrade never holds the volume for the whole
32/// run. Implementations own that per-call lease and settlement.
33pub(crate) trait MigrationTransactions {
34    /// Run one write transaction, or one autocommit write, under a fresh
35    /// admission.
36    fn admitted<T>(
37        &mut self,
38        operation: impl FnOnce(&mut Connection) -> Result<T, SqliteError>,
39    ) -> Result<T, SqliteError>;
40}
41
42/// Captured disk-guard settings and shared volume-lock directory for a SQLite
43/// writer.
44///
45/// Construction validates configuration without reading or writing a database.
46/// Cooperating callers must choose the same absolute volume-lock directory.
47#[derive(Clone, Debug)]
48pub struct MigrationWritePolicy {
49    disk_guard: crate::EffectiveDiskGuardConfig,
50    volume_lock_dir: PathBuf,
51}
52
53impl MigrationWritePolicy {
54    pub fn new(
55        disk_guard: crate::EffectiveDiskGuardConfig,
56        volume_lock_dir: impl Into<PathBuf>,
57    ) -> Result<Self, SqliteError> {
58        disk_guard.validate()?;
59        let volume_lock_dir = volume_lock_dir.into();
60        if !volume_lock_dir.is_absolute() {
61            return Err(SqliteError::InvalidConfig(
62                "migration volume-lock directory must be absolute".to_string(),
63            ));
64        }
65        Ok(Self {
66            disk_guard,
67            volume_lock_dir,
68        })
69    }
70
71    /// Resolve both settings from the process environment. The lock directory
72    /// follows [`crate::default_volume_lock_dir`], the same per-user default the
73    /// public SQLite constructors use.
74    pub fn from_environment() -> Result<Self, SqliteError> {
75        let disk_guard = crate::DiskGuardEnvironment::capture().resolve(None, None)?;
76        Self::new(disk_guard, crate::default_volume_lock_dir()?)
77    }
78
79    pub fn disk_guard_config(&self) -> crate::EffectiveDiskGuardConfig {
80        self.disk_guard
81    }
82
83    pub fn volume_lock_dir(&self) -> &Path {
84        &self.volume_lock_dir
85    }
86}
87
88#[path = "session_identity_migration.rs"]
89mod session_identity_migration;
90
91mod memory_visibility;
92
93// =============================================================================
94// Legacy per-service migration API (preserved for backward compatibility)
95// =============================================================================
96
97/// A single legacy migration step within a `ServiceSchemaPlan`.
98pub struct Migration {
99    /// Unique identifier for this migration.
100    pub id: &'static str,
101    /// SQL to apply (forward direction).
102    pub up_sql: &'static str,
103    /// SQL to revert (optional).
104    pub down_sql: Option<&'static str>,
105    /// Optional read-only predicate: returns true if migration was already
106    /// applied through a mechanism other than the migration tracker. It runs
107    /// while this connection holds the SQLite write lock.
108    pub is_already_applied: Option<fn(&Connection) -> bool>,
109}
110
111/// A pack-scoped schema plan containing migrations for SQLite and Postgres.
112pub struct ServiceSchemaPlan {
113    /// Service name used as a key in the `_schema_versions` tracking table.
114    pub service: &'static str,
115    /// SQLite-specific migration steps, applied in order.
116    pub sqlite: &'static [Migration],
117    /// Postgres-specific migration steps (reserved for future use).
118    pub postgres: &'static [Migration],
119}
120
121const SCHEMA_VERSION_TABLE: &str = include_str!("../sql/schema-version-table.sql");
122
123/// Apply a pack-scoped schema plan, tracking each migration in `_schema_versions`.
124/// A mutable connection lets the wrapper retire an unsettled original handle.
125pub fn apply_schema_plan(
126    conn: &mut Connection,
127    plan: &ServiceSchemaPlan,
128) -> Result<(), SqliteError> {
129    let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
130    apply_schema_plan_with_admission(
131        &mut RawMigrationTransactions::new(conn, &admission),
132        plan,
133        &admission,
134    )
135}
136
137/// Apply a raw connection's schema plan with a captured policy and lock path.
138pub fn apply_schema_plan_with_policy(
139    conn: &mut Connection,
140    plan: &ServiceSchemaPlan,
141    policy: &MigrationWritePolicy,
142) -> Result<(), SqliteError> {
143    let admission =
144        WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
145    apply_schema_plan_with_admission(
146        &mut RawMigrationTransactions::new(conn, &admission),
147        plan,
148        &admission,
149    )
150}
151
152/// Apply a service schema plan. The tracking-table bootstrap and each
153/// migration are separate admitted write units (ADR-154 section 4).
154pub(crate) fn apply_schema_plan_with_admission(
155    writes: &mut impl MigrationTransactions,
156    plan: &ServiceSchemaPlan,
157    admission: &WriteAdmission,
158) -> Result<(), SqliteError> {
159    writes.admitted(|conn| {
160        admission.check()?;
161        conn.execute_batch(SCHEMA_VERSION_TABLE)?;
162        require_autocommit(conn, "schema-version bootstrap")
163    })?;
164
165    for migration in plan.sqlite {
166        writes.admitted(|conn| apply_service_migration(conn, plan, migration, admission))?;
167    }
168
169    Ok(())
170}
171
172/// Apply one service migration in its own IMMEDIATE transaction unless its
173/// predicate or the ledger says it has already been applied.
174fn apply_service_migration(
175    conn: &Connection,
176    plan: &ServiceSchemaPlan,
177    migration: &Migration,
178    admission: &WriteAdmission,
179) -> Result<(), SqliteError> {
180    // Serialize the admission decision with other writers. Checking the
181    // predicate or ledger before BEGIN IMMEDIATE lets a second opener see
182    // stale state and replay a migration after the first one commits.
183    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
184    if let Err(error) = admission.check() {
185        let rollback = tx.rollback();
186        return Err(capacity_refusal_after_rollback(
187            conn,
188            rollback,
189            error,
190            "service schema migration",
191        ));
192    }
193
194    // Check if custom predicate says it's already applied
195    if let Some(check) = migration.is_already_applied {
196        if check(&tx) {
197            return Ok(());
198        }
199    }
200
201    // Check if tracked as applied
202    let already: bool = tx.query_row(
203        "SELECT COUNT(*) > 0 FROM _schema_versions WHERE service = ?1 AND migration_id = ?2",
204        rusqlite::params![plan.service, migration.id],
205        |row| row.get(0),
206    )?;
207
208    if already {
209        return Ok(());
210    }
211
212    tx.execute_batch(migration.up_sql)?;
213
214    tx.execute(
215        "INSERT INTO _schema_versions (service, migration_id, applied_at) VALUES (?1, ?2, ?3)",
216        rusqlite::params![
217            plan.service,
218            migration.id,
219            chrono::Utc::now().timestamp_micros(),
220        ],
221    )?;
222    tx.commit()?;
223    Ok(())
224}
225
226fn require_autocommit(conn: &Connection, operation: &str) -> Result<(), SqliteError> {
227    if conn.is_autocommit() {
228        Ok(())
229    } else {
230        Err(SqliteError::InvalidData(format!(
231            "{operation} did not return the SQLite connection to autocommit"
232        )))
233    }
234}
235
236pub(crate) fn capacity_refusal_after_rollback(
237    conn: &Connection,
238    rollback: rusqlite::Result<()>,
239    refusal: SqliteError,
240    operation: &str,
241) -> SqliteError {
242    if let Err(error) = rollback {
243        return SqliteError::InvalidData(format!(
244            "{operation} capacity refusal could not roll back: {error}; \
245             initial refusal: {refusal}"
246        ));
247    }
248    if !conn.is_autocommit() {
249        return SqliteError::InvalidData(format!(
250            "{operation} capacity refusal rolled back without restoring autocommit; \
251             initial refusal: {refusal}"
252        ));
253    }
254    refusal
255}
256
257// =============================================================================
258// Versioned migration system
259// =============================================================================
260
261/// A single forward-only schema migration.
262///
263/// Migrations are applied in order from the current DB version to the target
264/// version. Each migration runs in its own transaction; a failure rolls back
265/// that migration and leaves the DB at the prior version.
266pub struct VersionedMigration {
267    /// Monotonically increasing version number, starting at 1.
268    pub version: u32,
269    /// Short human-readable name for the migration (used in the audit table).
270    pub name: &'static str,
271    /// SQL to apply this migration. May contain multiple statements separated
272    /// by semicolons; `execute_batch` runs them all.
273    pub up: &'static str,
274}
275
276// V1: complete schema, loaded from sql/schema.sql.
277// Fresh-start repo (v0.2.8) — all schema in one migration, no incremental versions.
278const V1_UP: &str = include_str!("../sql/schema.sql");
279
280const V2_UP: &str = include_str!("../sql/002-narrow-fts-sections-update-trigger.sql");
281
282const V3_UP: &str = include_str!("../sql/003-backfill-domain-mirror-atoms.sql");
283
284const V4_UP: &str = include_str!("../sql/004-fts-consolidation.sql");
285
286const V5_UP: &str = include_str!("../sql/005-unique-comm-external-id.sql");
287
288const V6_UP: &str = include_str!("../sql/006-brain-retune-driver.sql");
289
290const V7_UP: &str = include_str!("../sql/007-notes-seq.sql");
291
292const V8_UP: &str = include_str!("../sql/008-notes-seq-repair.sql");
293
294const V9_UP: &str = include_str!("../sql/009-entities-name-ci-index.sql");
295
296const V10_UP: &str = include_str!("../sql/010-entities-content-ref.sql");
297
298const V11_UP: &str = include_str!("../sql/011-ann-write-log.sql");
299
300const V12_UP: &str = include_str!("../sql/012-ann-write-log-model-seq-index.sql");
301
302const V13_UP: &str = include_str!("../sql/013-list-cursor-sequences.sql");
303
304const V14_UP: &str = include_str!("../sql/014-graph-edges-id-unique.sql");
305
306const V15_UP: &str = include_str!("../sql/015-serve-ledger-attribution.sql");
307
308const V16_UP: &str = include_str!("../sql/016-gtd-dependency-cycle-guards.sql");
309
310const V17_UP: &str = include_str!("../sql/017-agents-ddl.sql");
311
312const V18_UP: &str = include_str!("../sql/018-ann-consumer-pending.sql");
313
314const V19_UP: &str = include_str!("../sql/019-list-cursor-backfill-repair.sql");
315
316const V20_UP: &str = include_str!("../sql/020-blob-gc-claims.sql");
317
318const V22_UP: &str = include_str!("../sql/022-notes-unread-probe-recipient.sql");
319
320const V23_UP: &str = include_str!("../sql/023-fts-record-kind.sql");
321
322const V24_UP: &str = include_str!("../sql/024-fts-rowid-map.sql");
323
324const V25_UP: &str = include_str!("../sql/025-notes-unread-probe-recipient-direction.sql");
325
326const V26_UP: &str = include_str!("../sql/026-knowledge-fts-repair.sql");
327
328const V27_UP: &str = include_str!("../sql/027-notes-hot-property-indexes.sql");
329const V28_UP: &str = include_str!("../sql/028-notes-key.sql");
330
331const V29_UP: &str = include_str!("../sql/029-note-streams.sql");
332const V30_UP: &str = include_str!("../sql/030-tool-source-mounts.sql");
333const V31_UP: &str = include_str!("../sql/031-note-versions.sql");
334const V32_UP: &str = include_str!("../sql/032-knowledge-count-indexes.sql");
335const V33_UP: &str = include_str!("../sql/033-notes-message-recipient-direction.sql");
336const V34_UP: &str = include_str!("../sql/034-notes-namespace-created.sql");
337const V35_UP: &str = include_str!("../sql/035-notes-unread-probe-recipient-type-direction.sql");
338const V36_UP: &str = include_str!("../sql/036-events-operation-attribution.sql");
339const V37_UP: &str = include_str!("../sql/037-entity-versions.sql");
340const V38_UP: &str = include_str!("../sql/038-entities-legacy-type-index.sql");
341const V39_UP: &str = include_str!("../sql/039-knowledge-cursor-indexes.sql");
342const SESSION_IDENTITY_UP: &str = include_str!("../sql/040-session-source-scope.sql");
343const SESSION_IDENTITY_MIGRATION_NAME: &str = "session_source_scoped_identity";
344const V41_UP: &str = include_str!("../sql/041-sender-transport.sql");
345const V42_UP: &str = include_str!("../sql/042-comm-external-id-channel-scope.sql");
346const V43_UP: &str = include_str!("../sql/043-vector-provenance.sql");
347const V44_COLUMNS: &str = include_str!("../sql/044-comm-outbound-due-a-columns.sql");
348const V44_UP: &str = include_str!("../sql/044-comm-outbound-due-b-index.sql");
349
350/// V44 may follow a direct-store bootstrap, whose idempotent notes DDL
351/// already supplies the two columns. Backfill before building the index so
352/// existing future retries are excluded by its deadline range immediately.
353pub(crate) fn migrate_outbound_due_key(tx: &rusqlite::Transaction<'_>) -> rusqlite::Result<()> {
354    let has_column = |name: &str| -> rusqlite::Result<bool> {
355        tx.query_row(
356            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('notes') WHERE name = ?1)",
357            [name],
358            |row| row.get(0),
359        )
360    };
361    match (has_column("strict_due_key")?, has_column("due_source")?) {
362        (false, false) => tx.execute_batch(V44_COLUMNS)?,
363        (true, true) => {}
364        _ => return Err(rusqlite::Error::InvalidQuery),
365    }
366
367    let mut after_id = String::new();
368    let mut first_page = true;
369    loop {
370        let rows: Vec<(String, String)> = {
371            let comparator = if first_page { ">=" } else { ">" };
372            let mut stmt = tx.prepare(&format!(
373                "SELECT id, json_extract(properties, '$.next_attempt_at') FROM notes \
374                 WHERE id {comparator} ?1 AND json_type(properties, '$.next_attempt_at') = 'text' \
375                 ORDER BY id LIMIT 500"
376            ))?;
377            let collected = stmt
378                .query_map([&after_id], |row| Ok((row.get(0)?, row.get(1)?)))?
379                .collect::<rusqlite::Result<_>>()?;
380            collected
381        };
382        if rows.is_empty() {
383            break;
384        }
385        after_id = rows.last().expect("nonempty V44 page").0.clone();
386        first_page = false;
387        for (id, source) in rows {
388            if let Some(key) = crate::pool::strict_rfc3339_key(&source) {
389                tx.execute(
390                    "UPDATE notes SET strict_due_key = ?1, due_source = ?2 \
391                     WHERE id = ?3 AND (strict_due_key IS NOT ?1 OR due_source IS NOT ?2)",
392                    rusqlite::params![key, source, id],
393                )?;
394            }
395        }
396    }
397    tx.execute_batch(V44_UP)
398}
399
400const RECIPIENT_TRANSPORT_VERSION: u32 = 45;
401const V45_UP: &str = include_str!("../sql/045-recipient-transport.sql");
402const V46_UP: &str = include_str!("../sql/046-memory-visibility-receipts.sql");
403const V47_UP: &str = include_str!("../sql/047-attachment-role-quarantine.sql");
404const V49_UP: &str = include_str!("../sql/049-git-note-property-indexes.sql");
405
406const V50_UP: &str = include_str!("../sql/050-entity-list-plans.sql");
407const V51_UP: &str = include_str!("../sql/051-schedule-core-indexes.sql");
408const V52_UP: &str = include_str!("../sql/052-comm-core-indexes.sql");
409const V53_UP: &str = include_str!("../sql/053-entity-kind-list-order.sql");
410const MEMORY_VISIBILITY_CUTOVER_VERSION: u32 = 54;
411const V54_UP: &str = include_str!("../sql/054-memory-visibility-epochs.sql");
412
413const V55_UP: &str = include_str!("../sql/055-knowledge-cursor-domain-tags.sql");
414const V56_UP: &str = include_str!("../sql/056-atom-weights.sql");
415const V57_UP: &str = include_str!("../sql/057-retrieval-snapshots.sql");
416const V48_UP: &str = include_str!("../sql/048-acknowledgement-journal-a-table.sql");
417const ACKNOWLEDGEMENT_JOURNAL_INDEX: &str =
418    include_str!("../sql/048-acknowledgement-journal-b-index.sql");
419
420/// A ledger-tail replay keeps already-current journal state and retry metadata.
421pub(crate) fn migrate_acknowledgement_journal(
422    tx: &rusqlite::Transaction<'_>,
423) -> rusqlite::Result<()> {
424    let columns: i64 = tx.query_row(
425        "SELECT count(*) FROM pragma_table_info('comm_ack_work') \
426         WHERE name IN ('attempt_count','not_before','retirement_reason')",
427        [],
428        |row| row.get(0),
429    )?;
430    match columns {
431        0 => tx.execute_batch(V48_UP)?,
432        3 => {}
433        _ => return Err(rusqlite::Error::InvalidQuery),
434    }
435    tx.execute_batch(ACKNOWLEDGEMENT_JOURNAL_INDEX)
436}
437
438const V21_STAGE_UP: &str = include_str!("../sql/021-attachments-a-stage.sql");
439
440const V21_ATTACHMENT_FENCES_UP: &str = include_str!("../sql/021-attachments-b-claim-fences.sql");
441
442/// Core schema version reserved for ADR-121's attachments-first cutover.
443pub const ATTACHMENT_CUTOVER_VERSION: u32 = 21;
444
445/// The latest schema version this build's migration chain produces.
446///
447/// Terminal-version assertions belong on this, not on a hardcoded number:
448/// a literal decays into a wrong claim the next time a migration is added.
449pub fn latest_schema_version() -> u32 {
450    MIGRATIONS.last().map(|m| m.version).unwrap_or(0)
451}
452
453/// DDL for the `ann_write_log` delta table.
454///
455/// Shared between migration V11 and the belt-and-suspenders creation in
456/// `StorageBackend::vectors_for_namespace` (same pattern as
457/// [`EMBEDDING_MODELS_DDL`]): every database that hosts `vec_*` tables must
458/// also have the write log, or vector writes would fail on databases opened
459/// without `run_migrations()`. The `.sql` file is `IF NOT EXISTS`-idempotent.
460pub const ANN_WRITE_LOG_DDL: &str = V11_UP;
461
462/// DDL for the `ann_write_log` model/kind/field-leading index (ADR-118 §"Cost
463/// bound"), shared between migration V12 and the belt-and-suspenders creation
464/// in `StorageBackend::vectors_for_namespace` for the same reason as
465/// [`ANN_WRITE_LOG_DDL`].
466pub const ANN_WRITE_LOG_MODEL_SEQ_INDEX_DDL: &str = V12_UP;
467
468/// Idempotent DDL for pending ANN-consumer lifecycle metadata (#1479).
469///
470/// The V18 migration additionally translates legacy zero-watermark rows once.
471/// This constant deliberately contains only idempotent DDL: vector-store open
472/// paths may execute it repeatedly and must never demote a valid active
473/// checkpoint at sequence zero back to pending.
474pub const ANN_CONSUMER_PENDING_DDL: &str = include_str!("../sql/ann-consumer-pending-ddl.sql");
475
476/// Sidecar DDL registered in the migration ledger. Direct vector-store
477/// construction leaves this schema to the versioned migration.
478pub const VECTOR_PROVENANCE_DDL: &str = V43_UP;
479
480/// DDL for the `_embedding_models` registry table.
481///
482/// Shared between the V1 schema and the belt-and-suspenders creation in
483/// `StorageBackend::vectors_for_namespace`. Both sites reference this constant so
484/// the schema cannot silently diverge if the registry evolves.
485pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
486
487/// Canonical versioned migration ledger in ascending order.
488///
489/// [`run_migrations`] applies the ordinary prefix and may complete V21 through
490/// its zero-legacy-reference fast path. A legacy V20 database records V21 only
491/// when [`finalize_attachment_cutover`] commits the application-assisted
492/// cutover.
493pub const MIGRATIONS: &[VersionedMigration] = &[
494    VersionedMigration {
495        version: 1,
496        name: "initial_schema",
497        up: V1_UP,
498    },
499    VersionedMigration {
500        version: 2,
501        name: "narrow_fts_sections_update_trigger",
502        up: V2_UP,
503    },
504    VersionedMigration {
505        version: 3,
506        name: "backfill_domain_mirror_atoms",
507        up: V3_UP,
508    },
509    VersionedMigration {
510        version: 4,
511        name: "fts_consolidation",
512        up: V4_UP,
513    },
514    VersionedMigration {
515        version: 5,
516        name: "unique_comm_message_external_id",
517        up: V5_UP,
518    },
519    VersionedMigration {
520        version: 6,
521        name: "brain_retune_driver",
522        up: V6_UP,
523    },
524    VersionedMigration {
525        version: 7,
526        name: "notes_seq",
527        up: V7_UP,
528    },
529    VersionedMigration {
530        version: 8,
531        name: "notes_seq_repair",
532        up: V8_UP,
533    },
534    VersionedMigration {
535        version: 9,
536        name: "entities_name_ci_index",
537        up: V9_UP,
538    },
539    VersionedMigration {
540        version: 10,
541        name: "entities_content_ref",
542        up: V10_UP,
543    },
544    VersionedMigration {
545        version: 11,
546        name: "ann_write_log",
547        up: V11_UP,
548    },
549    VersionedMigration {
550        version: 12,
551        name: "ann_write_log_model_seq_index",
552        up: V12_UP,
553    },
554    VersionedMigration {
555        version: 13,
556        name: "list_cursor_sequences",
557        up: V13_UP,
558    },
559    VersionedMigration {
560        version: 14,
561        name: "graph_edges_id_unique",
562        up: V14_UP,
563    },
564    VersionedMigration {
565        version: 15,
566        name: "serve_ledger_attribution",
567        up: V15_UP,
568    },
569    VersionedMigration {
570        version: 16,
571        name: "gtd_dependency_cycle_guards",
572        up: V16_UP,
573    },
574    VersionedMigration {
575        version: 17,
576        name: "agents_ddl",
577        up: V17_UP,
578    },
579    VersionedMigration {
580        version: 18,
581        name: "ann_consumer_pending",
582        up: V18_UP,
583    },
584    VersionedMigration {
585        version: 19,
586        name: "list_cursor_backfill_repair",
587        up: V19_UP,
588    },
589    VersionedMigration {
590        version: 20,
591        name: "blob_gc_claims",
592        up: V20_UP,
593    },
594    VersionedMigration {
595        version: ATTACHMENT_CUTOVER_VERSION,
596        name: "attachments_first_class",
597        // V21 is coordinated rather than an unconditional SQL migration.
598        // The runner special-cases it below; exposing the stage DDL here keeps
599        // the ledger entry self-describing for migration inspection tooling.
600        up: V21_STAGE_UP,
601    },
602    VersionedMigration {
603        version: 22,
604        name: "notes_unread_probe_recipient",
605        up: V22_UP,
606    },
607    VersionedMigration {
608        version: 23,
609        name: "fts_record_kind",
610        up: V23_UP,
611    },
612    VersionedMigration {
613        version: 24,
614        name: "fts_rowid_map",
615        up: V24_UP,
616    },
617    VersionedMigration {
618        version: 25,
619        name: "notes_unread_probe_recipient_direction",
620        up: V25_UP,
621    },
622    VersionedMigration {
623        version: 26,
624        name: "knowledge_fts_repair",
625        up: V26_UP,
626    },
627    VersionedMigration {
628        version: 27,
629        name: "notes_hot_property_indexes",
630        up: V27_UP,
631    },
632    VersionedMigration {
633        version: 28,
634        name: "notes_key",
635        up: V28_UP,
636    },
637    VersionedMigration {
638        version: 29,
639        name: "note_streams",
640        up: V29_UP,
641    },
642    VersionedMigration {
643        version: 30,
644        name: "tool_source_mounts",
645        up: V30_UP,
646    },
647    VersionedMigration {
648        version: 31,
649        name: "note_versions",
650        up: V31_UP,
651    },
652    VersionedMigration {
653        version: 32,
654        name: "knowledge_count_indexes",
655        up: V32_UP,
656    },
657    VersionedMigration {
658        version: 33,
659        name: "notes_message_recipient_direction",
660        up: V33_UP,
661    },
662    VersionedMigration {
663        version: 34,
664        name: "notes_namespace_created",
665        up: V34_UP,
666    },
667    VersionedMigration {
668        version: 35,
669        name: "notes_unread_probe_recipient_type_direction",
670        up: V35_UP,
671    },
672    VersionedMigration {
673        version: 36,
674        name: "events_operation_attribution",
675        up: V36_UP,
676    },
677    VersionedMigration {
678        version: 37,
679        name: "entity_versions",
680        up: V37_UP,
681    },
682    VersionedMigration {
683        version: 38,
684        name: "entities_legacy_type_index",
685        up: V38_UP,
686    },
687    VersionedMigration {
688        version: 39,
689        name: "knowledge_cursor_indexes",
690        up: V39_UP,
691    },
692    VersionedMigration {
693        version: 40,
694        name: SESSION_IDENTITY_MIGRATION_NAME,
695        up: SESSION_IDENTITY_UP,
696    },
697    VersionedMigration {
698        version: 41,
699        name: "sender_transport",
700        up: V41_UP,
701    },
702    VersionedMigration {
703        version: 42,
704        name: "comm_external_id_channel_scope",
705        up: V42_UP,
706    },
707    VersionedMigration {
708        version: 43,
709        name: "vector_provenance",
710        up: V43_UP,
711    },
712    VersionedMigration {
713        version: 44,
714        name: "comm_outbound_due",
715        up: V44_UP,
716    },
717    VersionedMigration {
718        version: RECIPIENT_TRANSPORT_VERSION,
719        name: "recipient_transport",
720        up: V45_UP,
721    },
722    VersionedMigration {
723        version: 46,
724        name: "memory_visibility_receipts",
725        up: V46_UP,
726    },
727    VersionedMigration {
728        version: 47,
729        name: "attachment_role_quarantine",
730        up: V47_UP,
731    },
732    VersionedMigration {
733        version: 48,
734        name: "acknowledgement_journal",
735        up: V48_UP,
736    },
737    VersionedMigration {
738        version: 49,
739        name: "git_note_property_indexes",
740        up: V49_UP,
741    },
742    VersionedMigration {
743        version: 50,
744        name: "entity_list_plans",
745        up: V50_UP,
746    },
747    VersionedMigration {
748        version: 51,
749        name: "schedule_core_indexes",
750        up: V51_UP,
751    },
752    VersionedMigration {
753        version: 52,
754        name: "comm_core_indexes",
755        up: V52_UP,
756    },
757    VersionedMigration {
758        version: 53,
759        name: "entity_kind_list_order",
760        up: V53_UP,
761    },
762    VersionedMigration {
763        version: MEMORY_VISIBILITY_CUTOVER_VERSION,
764        name: "memory_visibility_epochs",
765        up: V54_UP,
766    },
767    VersionedMigration {
768        version: 55,
769        name: "knowledge_atom_cursor_all_live_tags",
770        up: V55_UP,
771    },
772    VersionedMigration {
773        version: 56,
774        name: "atom_weight_tables",
775        up: V56_UP,
776    },
777    VersionedMigration {
778        version: 57,
779        name: "retrieval_snapshot_tables",
780        up: V57_UP,
781    },
782];
783
784/// Durable state of ADR-121's boot-gated, two-stage attachment cutover.
785#[derive(Clone, Copy, Debug, Eq, PartialEq)]
786pub enum AttachmentCutoverStatus {
787    /// V20 is current and no stage marker has been committed.
788    Pending,
789    /// Stage 1 committed; boot must finish verified pack-owned attachments.
790    Incomplete,
791    /// V21, the attachment fences, and the attachment-only schema committed.
792    Complete,
793}
794
795fn schema_object_exists(
796    conn: &Connection,
797    object_type: &str,
798    name: &str,
799) -> Result<bool, SqliteError> {
800    conn.query_row(
801        "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = ?1 AND name = ?2",
802        rusqlite::params![object_type, name],
803        |row| row.get(0),
804    )
805    .map_err(Into::into)
806}
807
808fn schema_column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, SqliteError> {
809    conn.query_row(
810        "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
811        rusqlite::params![table, column],
812        |row| row.get(0),
813    )
814    .map_err(Into::into)
815}
816
817fn require_attachment_schema_objects(
818    conn: &Connection,
819    objects: &[(&str, &str)],
820    phase: &str,
821) -> Result<(), SqliteError> {
822    for (object_type, name) in objects {
823        if !schema_object_exists(conn, object_type, name)? {
824            return Err(SqliteError::InvalidData(format!(
825                "attachment cutover {phase} state is missing {object_type} {name:?}"
826            )));
827        }
828    }
829    Ok(())
830}
831
832fn validate_incomplete_attachment_schema(conn: &Connection) -> Result<(), SqliteError> {
833    require_attachment_schema_objects(
834        conn,
835        &[
836            ("table", "attachments"),
837            ("index", "idx_attachments_content_ref"),
838        ],
839        "incomplete",
840    )?;
841    require_legacy_attachment_fences(conn)
842}
843
844fn validate_complete_attachment_schema(conn: &Connection) -> Result<(), SqliteError> {
845    require_attachment_schema_objects(
846        conn,
847        &[
848            ("table", "attachments"),
849            ("table", "blob_gc_claims"),
850            ("index", "idx_attachments_content_ref"),
851            ("index", "idx_blob_gc_claims_content_ref"),
852            ("trigger", "attachments_reject_claimed_blob_insert"),
853            ("trigger", "attachments_reject_claimed_blob_update"),
854        ],
855        "complete",
856    )?;
857    if schema_column_exists(conn, "entities", "content_ref")? {
858        return Err(SqliteError::InvalidData(
859            "attachment cutover is complete but entities.content_ref still exists".into(),
860        ));
861    }
862    for (object_type, name) in [
863        ("index", "idx_entities_content_ref"),
864        ("trigger", "entities_reject_claimed_blob_insert"),
865        ("trigger", "entities_reject_claimed_blob_update"),
866    ] {
867        if schema_object_exists(conn, object_type, name)? {
868            return Err(SqliteError::InvalidData(format!(
869                "attachment cutover is complete but legacy {object_type} {name:?} still exists"
870            )));
871        }
872    }
873    Ok(())
874}
875
876/// Inspect the coordinated V21 state without mutating the connection.
877///
878/// The marker and migration ledger form one state machine. Impossible pairs
879/// fail closed instead of being guessed into a resumable state.
880pub fn attachment_cutover_status(
881    conn: &Connection,
882) -> Result<AttachmentCutoverStatus, SqliteError> {
883    let version = read_schema_version(conn)?;
884    let marker_table = schema_object_exists(conn, "table", "attachment_cutover_state")?;
885    if !marker_table {
886        if version >= ATTACHMENT_CUTOVER_VERSION {
887            return Err(SqliteError::InvalidData(format!(
888                "migration V{ATTACHMENT_CUTOVER_VERSION} is recorded but its attachment cutover marker is absent"
889            )));
890        }
891        if schema_object_exists(conn, "table", "attachments")? {
892            return Err(SqliteError::InvalidData(
893                "attachments table exists without the durable attachment cutover marker".into(),
894            ));
895        }
896        return Ok(AttachmentCutoverStatus::Pending);
897    }
898
899    let marker: Option<(String, Option<i64>)> = conn
900        .query_row(
901            "SELECT state, completed_at FROM attachment_cutover_state WHERE singleton = 1",
902            [],
903            |row| Ok((row.get(0)?, row.get(1)?)),
904        )
905        .optional()?;
906    match marker {
907        Some((state, None)) if state == "incomplete" => {
908            if version >= ATTACHMENT_CUTOVER_VERSION {
909                Err(SqliteError::InvalidData(format!(
910                    "attachment cutover is incomplete but migration V{ATTACHMENT_CUTOVER_VERSION} is already recorded"
911                )))
912            } else {
913                validate_incomplete_attachment_schema(conn)?;
914                Ok(AttachmentCutoverStatus::Incomplete)
915            }
916        }
917        Some((state, Some(_))) if state == "complete" => {
918            // Later migrations (V22+) are recorded on top of a completed
919            // cutover in the normal course; only a ledger BELOW V21 beside a
920            // complete marker is an impossible pair.
921            if version >= ATTACHMENT_CUTOVER_VERSION {
922                validate_complete_attachment_schema(conn)?;
923                Ok(AttachmentCutoverStatus::Complete)
924            } else {
925                Err(SqliteError::InvalidData(format!(
926                    "attachment cutover is complete but schema ledger is at V{version}, below V{ATTACHMENT_CUTOVER_VERSION}"
927                )))
928            }
929        }
930        Some((state, completed_at)) => Err(SqliteError::InvalidData(format!(
931            "invalid attachment cutover marker state {state:?} with completed_at={completed_at:?}"
932        ))),
933        None => Err(SqliteError::InvalidData(
934            "attachment cutover marker table exists without its singleton row".into(),
935        )),
936    }
937}
938
939fn require_legacy_attachment_fences(conn: &Connection) -> Result<(), SqliteError> {
940    if !schema_column_exists(conn, "entities", "content_ref")? {
941        return Err(SqliteError::InvalidData(
942            "attachment cutover requires legacy entities.content_ref until finalization".into(),
943        ));
944    }
945    for (object_type, name) in [
946        ("table", "blob_gc_claims"),
947        ("index", "idx_blob_gc_claims_content_ref"),
948        ("index", "idx_entities_content_ref"),
949        ("trigger", "entities_reject_claimed_blob_insert"),
950        ("trigger", "entities_reject_claimed_blob_update"),
951    ] {
952        if !schema_object_exists(conn, object_type, name)? {
953            return Err(SqliteError::InvalidData(format!(
954                "attachment cutover requires legacy {object_type} {name:?} until finalization"
955            )));
956        }
957    }
958    Ok(())
959}
960
961// length() and GLOB both stop scanning at an embedded NUL, so a value of 64
962// hex characters followed by a NUL and arbitrary trailing bytes would pass
963// both. Deriving the canonical byte width from the connection's own text
964// encoding (rather than assuming UTF-8) keeps this arm correct on a database
965// pinned to UTF-16 and fails closed if the probe returns something else,
966// matching the pattern already used by `validate_blob_gc_evidence`.
967fn canonical_content_ref_byte_width(conn: &Connection) -> Result<i64, SqliteError> {
968    let width: i64 = conn.query_row("SELECT length(CAST('x' AS BLOB))", [], |row| row.get(0))?;
969    if !(1..=4).contains(&width) {
970        return Err(SqliteError::InvalidData(format!(
971            "the text-encoding width probe returned {width}; refusing canonicality validation"
972        )));
973    }
974    Ok(width * 64)
975}
976
977fn validate_canonical_legacy_refs(conn: &Connection) -> Result<(), SqliteError> {
978    let canonical_bytes = canonical_content_ref_byte_width(conn)?;
979    let invalid: Option<String> = conn
980        .query_row(
981            "SELECT id FROM entities \
982             WHERE content_ref IS NOT NULL \
983               AND (typeof(content_ref) <> 'text' \
984                 OR length(content_ref) <> 64 \
985                 OR length(CAST(content_ref AS BLOB)) <> ?1 \
986                 OR content_ref GLOB '*[^0-9a-f]*') \
987             LIMIT 1",
988            [canonical_bytes],
989            |row| row.get(0),
990        )
991        .optional()?;
992    if let Some(id) = invalid {
993        return Err(SqliteError::InvalidData(format!(
994            "entities.content_ref for record {id:?} is not a canonical 64-character lowercase hexadecimal ContentRef"
995        )));
996    }
997    Ok(())
998}
999
1000fn validate_canonical_attachment_and_claim_refs(conn: &Connection) -> Result<(), SqliteError> {
1001    let canonical_bytes = canonical_content_ref_byte_width(conn)?;
1002    for (table, identity) in [
1003        ("attachments", "record_uuid"),
1004        ("blob_gc_claims", "root_key"),
1005    ] {
1006        let sql = format!(
1007            "SELECT {identity} FROM {table} \
1008             WHERE typeof(content_ref) <> 'text' \
1009                OR length(content_ref) <> 64 \
1010                OR length(CAST(content_ref AS BLOB)) <> ?1 \
1011                OR content_ref GLOB '*[^0-9a-f]*' \
1012             LIMIT 1"
1013        );
1014        let invalid: Option<String> = conn
1015            .query_row(&sql, [canonical_bytes], |row| row.get(0))
1016            .optional()?;
1017        if let Some(owner) = invalid {
1018            return Err(SqliteError::InvalidData(format!(
1019                "{table}.content_ref for {identity} {owner:?} is not canonical"
1020            )));
1021        }
1022    }
1023    Ok(())
1024}
1025
1026fn validate_attachment_record_owners(conn: &Connection) -> Result<(), SqliteError> {
1027    let dangling: Option<(String, String)> = conn
1028        .query_row(
1029            "SELECT record_uuid, substrate FROM attachments AS attachment \
1030             WHERE (substrate = 'entity' AND NOT EXISTS ( \
1031                       SELECT 1 FROM entities WHERE id = attachment.record_uuid \
1032                   )) \
1033                OR (substrate = 'note' AND NOT EXISTS ( \
1034                       SELECT 1 FROM notes WHERE id = attachment.record_uuid \
1035                   )) \
1036             LIMIT 1",
1037            [],
1038            |row| Ok((row.get(0)?, row.get(1)?)),
1039        )
1040        .optional()?;
1041    if let Some((record_uuid, substrate)) = dangling {
1042        return Err(SqliteError::InvalidData(format!(
1043            "attachment role references absent {substrate} record {record_uuid:?}"
1044        )));
1045    }
1046    Ok(())
1047}
1048
1049fn validate_legacy_content_backfill(conn: &Connection) -> Result<(), SqliteError> {
1050    let conflict: Option<String> = conn
1051        .query_row(
1052            "SELECT entity.id FROM entities AS entity \
1053             LEFT JOIN attachments AS attachment \
1054               ON attachment.record_uuid = entity.id AND attachment.role = 'content' \
1055             WHERE entity.content_ref IS NOT NULL \
1056               AND (attachment.record_uuid IS NULL \
1057                 OR attachment.substrate <> 'entity' \
1058                 OR attachment.content_ref <> entity.content_ref) \
1059             LIMIT 1",
1060            [],
1061            |row| row.get(0),
1062        )
1063        .optional()?;
1064    if let Some(record_uuid) = conflict {
1065        return Err(SqliteError::InvalidData(format!(
1066            "legacy content attachment for entity {record_uuid:?} is missing or conflicts with entities.content_ref"
1067        )));
1068    }
1069    Ok(())
1070}
1071
1072fn stage_attachment_cutover_on_connection(conn: &Connection, now: i64) -> Result<(), SqliteError> {
1073    require_legacy_attachment_fences(conn)?;
1074    conn.execute_batch(V21_STAGE_UP)?;
1075    validate_canonical_legacy_refs(conn)?;
1076    validate_canonical_attachment_and_claim_refs(conn)?;
1077
1078    let conflict: Option<String> = conn
1079        .query_row(
1080            "SELECT entity.id FROM entities AS entity \
1081             JOIN attachments AS attachment \
1082               ON attachment.record_uuid = entity.id AND attachment.role = 'content' \
1083             WHERE entity.content_ref IS NOT NULL \
1084               AND (attachment.substrate <> 'entity' \
1085                 OR attachment.content_ref <> entity.content_ref) \
1086             LIMIT 1",
1087            [],
1088            |row| row.get(0),
1089        )
1090        .optional()?;
1091    if let Some(record_uuid) = conflict {
1092        return Err(SqliteError::InvalidData(format!(
1093            "existing content attachment for entity {record_uuid:?} conflicts with entities.content_ref"
1094        )));
1095    }
1096
1097    conn.execute(
1098        "INSERT INTO attachments \
1099         (record_uuid, substrate, role, content_ref, media_type, size_bytes, created_at) \
1100         SELECT id, 'entity', 'content', content_ref, NULL, NULL, created_at \
1101         FROM entities WHERE content_ref IS NOT NULL \
1102         ON CONFLICT(record_uuid, role) DO NOTHING",
1103        [],
1104    )?;
1105    validate_legacy_content_backfill(conn)?;
1106
1107    // The caller holds the canonical database GC owner, so every preexisting
1108    // claim is abandoned. Clearing happens before the durable incomplete
1109    // marker is exposed and remains inside this one transaction.
1110    conn.execute("DELETE FROM blob_gc_claims", [])?;
1111    conn.execute(
1112        "INSERT INTO attachment_cutover_state \
1113         (singleton, state, started_at, completed_at) \
1114         VALUES (1, 'incomplete', ?1, NULL) \
1115         ON CONFLICT(singleton) DO NOTHING",
1116        [now],
1117    )?;
1118    Ok(())
1119}
1120
1121/// Commit stage 1 of the coordinated V21 migration.
1122///
1123/// The caller must hold [`crate::stores::blob::DatabaseGcOwnerGuard`] for the
1124/// canonical database before entering this function and retain it through
1125/// application backfill and finalization. This function owns one IMMEDIATE
1126/// SQLite transaction; a failure leaves neither its DDL nor marker visible.
1127pub fn stage_attachment_cutover(conn: &mut Connection) -> Result<(), SqliteError> {
1128    let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
1129    RawMigrationWriteUnit::new(conn, &admission)?
1130        .run(|conn| stage_attachment_cutover_with_admission(conn, &admission))
1131}
1132
1133/// Stage attachment cutover with an explicit raw-connection policy.
1134pub fn stage_attachment_cutover_with_policy(
1135    conn: &mut Connection,
1136    policy: &MigrationWritePolicy,
1137) -> Result<(), SqliteError> {
1138    let admission =
1139        WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
1140    RawMigrationWriteUnit::new(conn, &admission)?
1141        .run(|conn| stage_attachment_cutover_with_admission(conn, &admission))
1142}
1143
1144/// Stage attachment cutover using admission already held by a pooled caller.
1145pub(crate) fn stage_attachment_cutover_with_admission(
1146    conn: &mut Connection,
1147    admission: &WriteAdmission,
1148) -> Result<(), SqliteError> {
1149    match attachment_cutover_status(conn)? {
1150        AttachmentCutoverStatus::Complete => return Ok(()),
1151        AttachmentCutoverStatus::Pending | AttachmentCutoverStatus::Incomplete => {}
1152    }
1153    if read_schema_version(conn)? != ATTACHMENT_CUTOVER_VERSION - 1 {
1154        return Err(SqliteError::InvalidData(format!(
1155            "attachment cutover stage requires canonical V{} schema",
1156            ATTACHMENT_CUTOVER_VERSION - 1
1157        )));
1158    }
1159
1160    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1161    if let Err(error) = admission.check() {
1162        let rollback = tx.rollback();
1163        return Err(capacity_refusal_after_rollback(
1164            conn,
1165            rollback,
1166            error,
1167            "attachment cutover stage",
1168        ));
1169    }
1170    let status = attachment_cutover_status(&tx)?;
1171    if status == AttachmentCutoverStatus::Complete {
1172        return Ok(());
1173    }
1174    stage_attachment_cutover_on_connection(&tx, chrono::Utc::now().timestamp_micros())?;
1175    tx.commit()?;
1176    Ok(())
1177}
1178
1179/// Add one host-verified pack-owned attachment during V21 stage 2.
1180///
1181/// This helper is deliberately transaction-neutral: it neither begins nor
1182/// commits a transaction. The boot coordinator can therefore apply the full
1183/// verified vector in one caller-owned IMMEDIATE transaction while retaining
1184/// the canonical database GC owner. Reapplying the same role and content is
1185/// idempotent; a different substrate or digest for that role fails closed.
1186#[allow(clippy::too_many_arguments)]
1187pub fn apply_generic_verified_attachment(
1188    conn: &Connection,
1189    record_uuid: &str,
1190    substrate: &str,
1191    role: &str,
1192    content_ref: &ContentRef,
1193    media_type: Option<&str>,
1194    size_bytes: Option<u64>,
1195    created_at: i64,
1196) -> Result<(), SqliteError> {
1197    if attachment_cutover_status(conn)? != AttachmentCutoverStatus::Incomplete {
1198        return Err(SqliteError::InvalidData(
1199            "verified application attachments may only be applied while V21 cutover is incomplete"
1200                .into(),
1201        ));
1202    }
1203    if role.is_empty() || role.chars().any(char::is_control) {
1204        return Err(SqliteError::InvalidData(
1205            "attachment role must be non-empty and contain no control characters".into(),
1206        ));
1207    }
1208    let size_bytes = size_bytes.map(i64::try_from).transpose().map_err(|_| {
1209        SqliteError::InvalidData("attachment size_bytes exceeds SQLite INTEGER".into())
1210    })?;
1211    let owner_table = match substrate {
1212        "entity" => "entities",
1213        "note" => "notes",
1214        other => {
1215            return Err(SqliteError::InvalidData(format!(
1216                "attachment substrate must be 'entity' or 'note', got {other:?}"
1217            )))
1218        }
1219    };
1220    let owner_sql = format!("SELECT COUNT(*) > 0 FROM {owner_table} WHERE id = ?1");
1221    let owner_exists: bool = conn.query_row(&owner_sql, [record_uuid], |row| row.get(0))?;
1222    if !owner_exists {
1223        return Err(SqliteError::InvalidData(format!(
1224            "cannot attach role {role:?}: {substrate} record {record_uuid:?} does not exist"
1225        )));
1226    }
1227    let claimed: bool = conn.query_row(
1228        "SELECT COUNT(*) > 0 FROM blob_gc_claims WHERE content_ref = ?1",
1229        [content_ref.as_str()],
1230        |row| row.get(0),
1231    )?;
1232    if claimed {
1233        return Err(SqliteError::InvalidData(format!(
1234            "cannot attach claimed content_ref {} during V21 cutover",
1235            content_ref.as_str()
1236        )));
1237    }
1238
1239    let changed = conn.execute(
1240        "INSERT INTO attachments \
1241         (record_uuid, substrate, role, content_ref, media_type, size_bytes, created_at) \
1242         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
1243         ON CONFLICT(record_uuid, role) DO UPDATE SET \
1244             media_type = excluded.media_type, \
1245             size_bytes = excluded.size_bytes, \
1246             created_at = excluded.created_at \
1247         WHERE attachments.substrate = excluded.substrate \
1248           AND attachments.content_ref = excluded.content_ref",
1249        rusqlite::params![
1250            record_uuid,
1251            substrate,
1252            role,
1253            content_ref.as_str(),
1254            media_type,
1255            size_bytes,
1256            created_at,
1257        ],
1258    )?;
1259    if changed == 0 {
1260        return Err(SqliteError::InvalidData(format!(
1261            "attachment role {role:?} for record {record_uuid:?} conflicts with an existing substrate or content_ref"
1262        )));
1263    }
1264    Ok(())
1265}
1266
1267fn finalize_attachment_cutover_on_connection(
1268    conn: &Connection,
1269    now: i64,
1270) -> Result<(), SqliteError> {
1271    require_legacy_attachment_fences(conn)?;
1272    validate_canonical_legacy_refs(conn)?;
1273    validate_canonical_attachment_and_claim_refs(conn)?;
1274    validate_attachment_record_owners(conn)?;
1275    validate_legacy_content_backfill(conn)?;
1276
1277    let remaining_claims: i64 =
1278        conn.query_row("SELECT COUNT(*) FROM blob_gc_claims", [], |row| row.get(0))?;
1279    if remaining_claims != 0 {
1280        return Err(SqliteError::InvalidData(format!(
1281            "attachment cutover cannot finalize while {remaining_claims} blob GC claim rows remain"
1282        )));
1283    }
1284
1285    let uncovered_model: Option<String> = conn
1286        .query_row(
1287            "SELECT model.id FROM entities AS model \
1288             WHERE model.entity_type = 'moodboard_model' \
1289               AND model.content_ref IS NOT NULL \
1290               AND NOT EXISTS ( \
1291                   SELECT 1 FROM attachments AS attachment \
1292                   WHERE attachment.record_uuid = model.id \
1293                     AND attachment.substrate = 'entity' \
1294                     AND attachment.role = 'fann-network' \
1295               ) \
1296             LIMIT 1",
1297            [],
1298            |row| row.get(0),
1299        )
1300        .optional()?;
1301    if let Some(record_uuid) = uncovered_model {
1302        return Err(SqliteError::InvalidData(format!(
1303            "moodboard_model {record_uuid:?} has legacy content but no verified 'fann-network' attachment"
1304        )));
1305    }
1306
1307    conn.execute_batch(V21_ATTACHMENT_FENCES_UP)?;
1308    conn.execute_batch(
1309        "DROP TRIGGER entities_reject_claimed_blob_insert; \
1310         DROP TRIGGER entities_reject_claimed_blob_update; \
1311         DROP INDEX idx_entities_content_ref; \
1312         ALTER TABLE entities DROP COLUMN content_ref;",
1313    )?;
1314    conn.execute(
1315        "UPDATE attachment_cutover_state \
1316         SET state = 'complete', completed_at = ?1 \
1317         WHERE singleton = 1 AND state = 'incomplete'",
1318        [now],
1319    )?;
1320    Ok(())
1321}
1322
1323fn record_attachment_cutover_migration(conn: &Connection, now: i64) -> Result<(), SqliteError> {
1324    let migration = MIGRATIONS
1325        .iter()
1326        .find(|migration| migration.version == ATTACHMENT_CUTOVER_VERSION)
1327        .expect("V21 migration must be registered");
1328    conn.execute(
1329        "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
1330        rusqlite::params![migration.version, migration.name, now],
1331    )?;
1332    Ok(())
1333}
1334
1335/// Atomically switch an explicitly staged database to attachment-only V21.
1336///
1337/// The caller must still hold the canonical database GC owner. The exclusive
1338/// transition revalidates every legacy and attachment reference, verifies
1339/// moodboard model role coverage, replaces the claim fences, removes the old
1340/// column, marks the cutover complete, and records V21 in one transaction.
1341pub fn finalize_attachment_cutover(conn: &mut Connection) -> Result<(), SqliteError> {
1342    let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
1343    RawMigrationWriteUnit::new(conn, &admission)?
1344        .run(|conn| finalize_attachment_cutover_with_admission(conn, &admission))
1345}
1346
1347/// Finalize attachment cutover with an explicit raw-connection policy.
1348pub fn finalize_attachment_cutover_with_policy(
1349    conn: &mut Connection,
1350    policy: &MigrationWritePolicy,
1351) -> Result<(), SqliteError> {
1352    let admission =
1353        WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
1354    RawMigrationWriteUnit::new(conn, &admission)?
1355        .run(|conn| finalize_attachment_cutover_with_admission(conn, &admission))
1356}
1357
1358/// Finalize attachment cutover using admission already held by a pooled caller.
1359pub(crate) fn finalize_attachment_cutover_with_admission(
1360    conn: &mut Connection,
1361    admission: &WriteAdmission,
1362) -> Result<(), SqliteError> {
1363    if attachment_cutover_status(conn)? == AttachmentCutoverStatus::Complete {
1364        return Ok(());
1365    }
1366    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?;
1367    if let Err(error) = admission.check() {
1368        let rollback = tx.rollback();
1369        return Err(capacity_refusal_after_rollback(
1370            conn,
1371            rollback,
1372            error,
1373            "attachment cutover finalization",
1374        ));
1375    }
1376    match attachment_cutover_status(&tx)? {
1377        AttachmentCutoverStatus::Complete => return Ok(()),
1378        AttachmentCutoverStatus::Pending => {
1379            return Err(SqliteError::InvalidData(
1380                "attachment cutover must complete stage 1 before finalization".into(),
1381            ))
1382        }
1383        AttachmentCutoverStatus::Incomplete => {}
1384    }
1385    let now = chrono::Utc::now().timestamp_micros();
1386    finalize_attachment_cutover_on_connection(&tx, now)?;
1387    record_attachment_cutover_migration(&tx, now)?;
1388    tx.commit()?;
1389    Ok(())
1390}
1391
1392/// Read the ordered migration ledger prefix without interpreting its rows.
1393fn read_applied_migration_ledger(
1394    conn: &Connection,
1395    through_version: u32,
1396) -> Result<Vec<(u32, String)>, SqliteError> {
1397    let mut stmt = conn.prepare(
1398        "SELECT version, name FROM _schema_migrations \
1399         WHERE version <= ?1 ORDER BY version ASC",
1400    )?;
1401    let rows = stmt
1402        .query_map([through_version], |row| {
1403            Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?))
1404        })?
1405        .collect::<Result<Vec<_>, _>>()?;
1406    Ok(rows)
1407}
1408
1409/// Require the applied versions through `through_version` to be the exact
1410/// contiguous canonical prefix of [`MIGRATIONS`]. A matching `MAX(version)` is
1411/// insufficient: a missing middle row or foreign version can expose a
1412/// materially different schema while retaining the same maximum.
1413fn validate_applied_migration_versions(
1414    applied: &[(u32, String)],
1415    through_version: u32,
1416) -> Result<(), SqliteError> {
1417    let expected: Vec<&VersionedMigration> = MIGRATIONS
1418        .iter()
1419        .filter(|migration| migration.version <= through_version)
1420        .collect();
1421    let mut applied_index = 0;
1422
1423    for migration in expected {
1424        let Some((version, applied_name)) = applied.get(applied_index) else {
1425            return Err(SqliteError::InvalidData(format!(
1426                "migration history is missing version {} ('{}'); the applied ledger must be \
1427                 the exact contiguous canonical sequence through version {through_version}",
1428                migration.version, migration.name,
1429            )));
1430        };
1431        if *version < migration.version {
1432            return Err(SqliteError::InvalidData(format!(
1433                "migration history contains unknown version {version} recorded as \
1434                 '{applied_name}'; the applied ledger must contain only canonical versions"
1435            )));
1436        }
1437        if *version > migration.version {
1438            return Err(SqliteError::InvalidData(format!(
1439                "migration history is missing version {} ('{}'); found version {version} \
1440                 next instead",
1441                migration.version, migration.name,
1442            )));
1443        }
1444        applied_index += 1;
1445    }
1446
1447    if let Some((version, name)) = applied.get(applied_index) {
1448        return Err(SqliteError::InvalidData(format!(
1449            "migration history contains unknown version {version} recorded as '{name}'; \
1450             the applied ledger must contain only canonical versions"
1451        )));
1452    }
1453
1454    Ok(())
1455}
1456
1457fn validate_applied_migration_names(
1458    applied: &[(u32, String)],
1459    through_version: u32,
1460    allow_known_v19_repairs: bool,
1461) -> Result<(), SqliteError> {
1462    for ((version, applied_name), migration) in applied.iter().zip(
1463        MIGRATIONS
1464            .iter()
1465            .filter(|migration| migration.version <= through_version),
1466    ) {
1467        debug_assert_eq!(*version, migration.version);
1468        if migration.name != applied_name.as_str() {
1469            if allow_known_v19_repairs && matches!(*version, 13 | 14) {
1470                continue;
1471            }
1472            return Err(SqliteError::InvalidData(format!(
1473                "migration version {version} is recorded under name '{applied_name}', \
1474                 expected '{expected}'. This database's migration history does not match \
1475                 the current binary; recreate it from the current schema or repair the \
1476                 specific known divergence via a dedicated migration.",
1477                expected = migration.name,
1478            )));
1479        }
1480    }
1481
1482    Ok(())
1483}
1484
1485/// Confirm the complete applied ledger is the canonical prefix, including
1486/// names. The only historical V13/V14 name divergence is repaired by V19
1487/// before this validator runs for a pre-V19 database.
1488fn validate_applied_migration_ledger(
1489    conn: &Connection,
1490    through_version: u32,
1491) -> Result<(), SqliteError> {
1492    let applied = read_applied_migration_ledger(conn, through_version)?;
1493    validate_applied_migration_versions(&applied, through_version)?;
1494    validate_applied_migration_names(&applied, through_version, false)
1495}
1496
1497const MIGRATION_TRACKING_TABLE: &str = include_str!("../sql/schema-migrations-table.sql");
1498
1499const SCHEMA_VERSION_PROBE_SQL: &str = "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations";
1500
1501/// Build the schema-ledger version query without opening a reader or decoding its result.
1502pub fn schema_version_probe() -> khive_storage::types::SqlStatement {
1503    khive_storage::types::SqlStatement::new(SCHEMA_VERSION_PROBE_SQL, Vec::new())
1504}
1505
1506/// Read the applied schema version from an open connection **without** running
1507/// migrations. Returns 0 when the `_schema_migrations` ledger is absent (an
1508/// un-migrated or empty database); any other failure (BUSY, IO) propagates —
1509/// collapsing it to 0 would misreport a live database as un-migrated. Never
1510/// writes.
1511pub fn read_schema_version(conn: &Connection) -> Result<u32, SqliteError> {
1512    match conn.query_row(SCHEMA_VERSION_PROBE_SQL, [], |row| row.get(0)) {
1513        Ok(version) => Ok(version),
1514        Err(rusqlite::Error::SqliteFailure(_, Some(ref msg)))
1515            if msg.contains("no such table: _schema_migrations") =>
1516        {
1517            Ok(0)
1518        }
1519        Err(e) => Err(e.into()),
1520    }
1521}
1522
1523/// Open `path` read-only and report its applied schema version without creating
1524/// or migrating the file. The caller must ensure `path` exists — opening a
1525/// missing file read-only errors rather than creating it. This is the path used
1526/// by schema-inspection commands that must not mutate the database.
1527pub fn inspect_schema_version(path: &std::path::Path) -> Result<u32, SqliteError> {
1528    let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1529    read_schema_version(&conn)
1530}
1531
1532/// Open `path` read-only and require the exact canonical current ledger and
1533/// physical cutover state, without creating files or applying migrations.
1534pub fn inspect_schema_is_current(path: &std::path::Path) -> Result<u32, SqliteError> {
1535    let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1536    validate_schema_is_current(&conn)
1537}
1538
1539/// Page accounting for one database file. Each value is a single pragma read,
1540/// so the cost does not grow with the file.
1541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1542pub struct PageAccounting {
1543    /// `PRAGMA page_size`, in bytes.
1544    pub page_size: u64,
1545    /// `PRAGMA page_count`: every page in the database, free pages included.
1546    pub page_count: u64,
1547    /// `PRAGMA freelist_count`: pages that later writes can reuse but that stay
1548    /// in the file until a `VACUUM`.
1549    pub freelist_count: u64,
1550}
1551
1552/// Open `path` read-only and report its page accounting without creating,
1553/// migrating or walking the file. The caller must ensure `path` exists.
1554pub fn inspect_page_accounting(path: &std::path::Path) -> Result<PageAccounting, SqliteError> {
1555    let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1556    let read = |pragma: &str| -> Result<u64, SqliteError> {
1557        let value: i64 = conn.query_row(pragma, [], |row| row.get(0))?;
1558        u64::try_from(value)
1559            .map_err(|_| SqliteError::InvalidData(format!("{pragma} returned {value}")))
1560    };
1561    Ok(PageAccounting {
1562        page_size: read("PRAGMA page_size")?,
1563        page_count: read("PRAGMA page_count")?,
1564        freelist_count: read("PRAGMA freelist_count")?,
1565    })
1566}
1567
1568/// Open `path` read-only and run `PRAGMA integrity_check` over the whole file,
1569/// returning every row SQLite reports: exactly `["ok"]` for a sound file. The
1570/// check reads every page, so its cost grows with the file. No error limit is
1571/// applied: the pragma's default stops at 100 rows, so the largest limit it
1572/// accepts is passed instead.
1573pub fn inspect_integrity(path: &std::path::Path) -> Result<Vec<String>, SqliteError> {
1574    let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1575    let mut statement = conn.prepare(&format!("PRAGMA integrity_check({})", i32::MAX))?;
1576    let rows = statement
1577        .query_map([], |row| row.get::<_, String>(0))?
1578        .collect::<Result<Vec<_>, _>>()?;
1579    Ok(rows)
1580}
1581
1582/// Require an already-open database to match this build's latest core schema
1583/// without applying migrations.
1584///
1585/// A read-only snapshot behind the current migration set cannot be repaired in
1586/// place, while a snapshot ahead of the binary may contain schema this build
1587/// does not understand. Both directions fail with an actionable diagnostic; an
1588/// exact match performs no writes.
1589pub fn validate_schema_is_current(conn: &Connection) -> Result<u32, SqliteError> {
1590    let current_version = read_schema_version(conn)?;
1591    let latest_version = latest_schema_version();
1592
1593    if current_version < latest_version {
1594        return Err(SqliteError::InvalidData(format!(
1595            "read-only database schema version {current_version} is behind the latest known \
1596             migration {latest_version}; migrate a writable copy with this build before opening \
1597             the snapshot read-only"
1598        )));
1599    }
1600    if current_version > latest_version {
1601        return Err(SqliteError::InvalidData(format!(
1602            "read-only database schema version {current_version} is ahead of the latest known \
1603             migration {latest_version}; use a compatible newer build or recreate the snapshot"
1604        )));
1605    }
1606
1607    // Numeric equality alone is not enough: a database can carry the current
1608    // maximum version under renamed or foreign migration ledger entries while
1609    // exposing a materially different schema. Writable boot runs this same
1610    // closed-name validation in `bootstrap_migration_ledger`; snapshot inspection
1611    // must not accept a history that ordinary boot would reject merely because
1612    // it cannot repair it in place.
1613    validate_applied_migration_ledger(conn, current_version)?;
1614    // `>=`, not `==`: later migrations (V22+) record on top of a completed
1615    // cutover, and a ledger at the latest version must not exempt the
1616    // physical cutover state from validation.
1617    if current_version >= ATTACHMENT_CUTOVER_VERSION
1618        && attachment_cutover_status(conn)? != AttachmentCutoverStatus::Complete
1619    {
1620        return Err(SqliteError::InvalidData(
1621            "read-only database has not completed the V21 attachment cutover".into(),
1622        ));
1623    }
1624
1625    if current_version >= MEMORY_VISIBILITY_CUTOVER_VERSION {
1626        memory_visibility::validate_cutover(conn)?;
1627    }
1628
1629    Ok(current_version)
1630}
1631
1632/// Require the complete core migration ledger and readable memory provenance
1633/// schema without applying migrations or inferring an individual note's epoch.
1634pub fn validate_memory_visibility_cutover(conn: &Connection) -> Result<(), SqliteError> {
1635    validate_schema_is_current(conn).map(|_| ())
1636}
1637
1638#[cfg(test)]
1639pub(crate) mod test_sync {
1640    use std::sync::atomic::AtomicU32;
1641    use std::sync::{Arc, Barrier, Mutex};
1642
1643    /// When set, `run_versioned_migrations` parks after its initial (stale)
1644    /// ledger read until every racing thread has arrived — forcing the
1645    /// contended interleaving the concurrent-boot test asserts on.
1646    pub(crate) static STALE_READ_BARRIER: Mutex<Option<Arc<Barrier>>> = Mutex::new(None);
1647    /// Counts entries into the under-lock sibling fast-forward branch.
1648    pub(crate) static LOCKED_FAST_FORWARDS: AtomicU32 = AtomicU32::new(0);
1649    /// Set by the SQLite busy handler installed on participating connections:
1650    /// `true` means SQLite itself reported a blocked lock acquisition to the
1651    /// loser — actual contention, not merely an intended attempt.
1652    pub(crate) static BUSY_OBSERVED: std::sync::atomic::AtomicBool =
1653        std::sync::atomic::AtomicBool::new(false);
1654
1655    /// Busy handler for participating test connections: records that SQLite
1656    /// observed a busy acquisition, then keeps retrying.
1657    pub(crate) fn record_busy(_count: i32) -> bool {
1658        BUSY_OBSERVED.store(true, std::sync::atomic::Ordering::SeqCst);
1659        std::thread::sleep(std::time::Duration::from_millis(1));
1660        true
1661    }
1662
1663    /// Set by the winner immediately before committing its first migration
1664    /// transaction — i.e. before the write lock is first released.
1665    pub(crate) static WINNER_COMMITTED: std::sync::atomic::AtomicBool =
1666        std::sync::atomic::AtomicBool::new(false);
1667    /// Recorded by the loser when its first `BEGIN IMMEDIATE` returns: whether
1668    /// the winner had already committed at that moment. `true` is direct
1669    /// evidence the loser's lock acquisition blocked across the winner's held
1670    /// write lock rather than the two calls serializing by scheduler accident.
1671    pub(crate) static LOSER_SAW_WINNER_COMMIT: std::sync::atomic::AtomicBool =
1672        std::sync::atomic::AtomicBool::new(false);
1673
1674    std::thread_local! {
1675        /// Opt-in flag: only threads that set this participate in the barrier,
1676        /// so unrelated tests migrating in parallel are never parked.
1677        pub(crate) static PARTICIPATE: std::cell::Cell<bool> =
1678            const { std::cell::Cell::new(false) };
1679        /// Whether this thread has already instrumented its first BEGIN.
1680        pub(crate) static FIRST_BEGIN_DONE: std::cell::Cell<bool> =
1681            const { std::cell::Cell::new(false) };
1682    }
1683}
1684
1685/// Apply the ordinary unapplied migration prefix in order.
1686///
1687/// The operation is idempotent and each ordinary migration runs in its own
1688/// transaction. V21 is the application-assisted exception: a database with no
1689/// legacy content references may complete V21 atomically here, while a legacy
1690/// database stops successfully at V20 so the async host can stage, verify, and
1691/// finalize the attachment cutover. Errors on a non-contiguous migration array,
1692/// a non-canonical applied ledger, or a failed migration.
1693fn canonical_connection_database_path(conn: &Connection) -> Result<Option<PathBuf>, SqliteError> {
1694    let configured = conn.path().unwrap_or_default();
1695    let raw_path = if configured.is_empty() {
1696        conn.query_row(
1697            "SELECT file FROM pragma_database_list WHERE name = 'main'",
1698            [],
1699            |row| row.get::<_, String>(0),
1700        )?
1701    } else {
1702        configured.to_string()
1703    };
1704
1705    if raw_path.is_empty() {
1706        return Ok(None);
1707    }
1708    let canonical = std::fs::canonicalize(&raw_path).map_err(SqliteError::Io)?;
1709    #[cfg(any(unix, windows))]
1710    crate::pool::opened_sqlite_file_identity(conn, &canonical)?;
1711    Ok(Some(canonical))
1712}
1713
1714fn validate_database_gc_owner(
1715    conn: &Connection,
1716    owner: &DatabaseGcOwnerGuard,
1717) -> Result<(), SqliteError> {
1718    let connection_path = canonical_connection_database_path(conn)?;
1719    if owner.database_path() != connection_path.as_deref() {
1720        return Err(SqliteError::InvalidData(format!(
1721            "database GC owner targets {:?}, but migration connection targets {:?}",
1722            owner.database_path(),
1723            connection_path.as_deref(),
1724        )));
1725    }
1726    Ok(())
1727}
1728
1729pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
1730    let database_path = canonical_connection_database_path(conn)?;
1731    let admission = WriteAdmission::for_canonical_path(database_path.clone())?;
1732    run_raw_migrations_with_admission(conn, database_path, &admission)
1733}
1734
1735/// Run migrations on a raw connection with a captured admission policy.
1736pub fn run_migrations_with_policy(
1737    conn: &mut Connection,
1738    policy: &MigrationWritePolicy,
1739) -> Result<u32, SqliteError> {
1740    let database_path = canonical_connection_database_path(conn)?;
1741    let admission = WriteAdmission::for_migration_policy(database_path.clone(), policy)?;
1742    run_raw_migrations_with_admission(conn, database_path, &admission)
1743}
1744
1745fn run_raw_migrations_with_admission(
1746    conn: &mut Connection,
1747    database_path: Option<PathBuf>,
1748    admission: &WriteAdmission,
1749) -> Result<u32, SqliteError> {
1750    if let Some(database_path) = database_path {
1751        // This raw API may have been handed a connection behind an opaque pool
1752        // writer guard. Never wait here and invert the canonical
1753        // owner-before-writer order; fail closed and direct production callers
1754        // to `StorageBackend::prepare_core_schema` instead.
1755        let owner = try_acquire_database_gc_owner_for_path(database_path).map_err(|error| {
1756            SqliteError::InvalidData(format!(
1757                "failed to acquire database GC owner before schema migration: {error}"
1758            ))
1759        })?;
1760        return run_migrations_with_database_gc_owner(
1761            &mut RawMigrationTransactions::new(conn, admission),
1762            &owner,
1763            admission,
1764        );
1765    }
1766
1767    // A raw in-memory connection has no durable/cross-process GC domain. The
1768    // production in-memory backend still uses the owner-aware path below.
1769    run_versioned_migrations(
1770        &mut RawMigrationTransactions::new(conn, admission),
1771        None,
1772        admission,
1773    )
1774}
1775
1776pub(crate) fn run_migrations_with_database_gc_owner(
1777    writes: &mut impl MigrationTransactions,
1778    owner: &DatabaseGcOwnerGuard,
1779    admission: &WriteAdmission,
1780) -> Result<u32, SqliteError> {
1781    run_versioned_migrations(writes, Some(owner), admission)
1782}
1783
1784/// Raise-only to a 5s busy_timeout floor for one admitted migration unit and
1785/// restore the caller's value after it. Concurrent boots (multiple processes
1786/// migrating the same file) contend on SQLite's write lock; a short hot-path
1787/// busy_timeout cannot wait out a sibling's migration, and a caller whose
1788/// configured timeout is already longer is never reduced.
1789fn with_migration_busy_timeout<T>(
1790    conn: &mut Connection,
1791    operation: impl FnOnce(&mut Connection) -> Result<T, SqliteError>,
1792) -> Result<T, SqliteError> {
1793    let prior_busy_ms: i64 = conn.query_row("PRAGMA busy_timeout", [], |row| row.get(0))?;
1794    let raised = prior_busy_ms < 5_000;
1795    if raised {
1796        conn.busy_timeout(std::time::Duration::from_secs(5))?;
1797    }
1798    let result = operation(conn);
1799    if raised {
1800        let _ = conn.busy_timeout(std::time::Duration::from_millis(prior_busy_ms.max(0) as u64));
1801    }
1802    result
1803}
1804
1805/// What one admitted versioned-migration transaction did.
1806enum MigrationStep {
1807    /// This transaction applied the migration and committed.
1808    Applied,
1809    /// A sibling had already recorded this version or later; the value is the
1810    /// ledger maximum read under the write lock.
1811    AppliedBySibling(u32),
1812    /// V21 on a legacy database: left for the application-assisted cutover.
1813    Deferred,
1814}
1815
1816/// Bring the core schema to the latest version.
1817///
1818/// ADR-154 section 4 admits each migration transaction on its own, so the
1819/// ledger bootstrap with the pre-lock ledger read is one admitted unit and
1820/// every migration that still has to run is another, re-reading the ledger
1821/// under the write lock. No volume lease is held from one version to the next.
1822fn run_versioned_migrations(
1823    writes: &mut impl MigrationTransactions,
1824    owner: Option<&DatabaseGcOwnerGuard>,
1825    admission: &WriteAdmission,
1826) -> Result<u32, SqliteError> {
1827    let current_version = writes.admitted(|conn| {
1828        if let Some(owner) = owner {
1829            validate_database_gc_owner(conn, owner)?;
1830        }
1831        with_migration_busy_timeout(conn, |conn| bootstrap_migration_ledger(conn, admission))
1832    })?;
1833
1834    // Deterministic-contention hook: parks every caller after the stale ledger
1835    // read (no SQLite lock and no volume lease held) until all racing test
1836    // threads have observed it, so they are then released to compete for the
1837    // IMMEDIATE write lock of their first migration.
1838    #[cfg(test)]
1839    if test_sync::PARTICIPATE.with(|p| p.get()) {
1840        let barrier = test_sync::STALE_READ_BARRIER.lock().unwrap().clone();
1841        if let Some(barrier) = barrier {
1842            barrier.wait();
1843        }
1844    }
1845    let latest_version = latest_schema_version();
1846
1847    let mut applied_version = current_version;
1848    // Floor advanced when a sibling's work is observed under the write lock,
1849    // so a losing process skips the remaining already-applied migrations
1850    // without opening a transaction for each.
1851    let mut skip_through = current_version;
1852
1853    for migration in MIGRATIONS {
1854        if migration.version <= skip_through {
1855            applied_version = applied_version.max(migration.version);
1856            continue;
1857        }
1858        let step = writes.admitted(|conn| {
1859            with_migration_busy_timeout(conn, |conn| {
1860                apply_versioned_migration(conn, migration, admission, latest_version)
1861            })
1862        })?;
1863        match step {
1864            MigrationStep::Applied => applied_version = migration.version,
1865            MigrationStep::AppliedBySibling(sibling_version) => {
1866                skip_through = sibling_version.min(latest_version);
1867                applied_version = applied_version.max(migration.version);
1868            }
1869            MigrationStep::Deferred => break,
1870        }
1871    }
1872
1873    // Validate again after the loop: our own commits and any under-lock
1874    // sibling fast-forward must both leave the exact canonical ledger, not
1875    // merely advance its maximum version.
1876    writes.admitted(|conn| validate_applied_migration_ledger(conn, applied_version))?;
1877
1878    Ok(applied_version)
1879}
1880
1881/// Create the migration ledger if it is missing and validate what it records,
1882/// returning the recorded version. The read is taken before any migration
1883/// write lock; each migration re-reads the ledger under that lock.
1884fn bootstrap_migration_ledger(
1885    conn: &mut Connection,
1886    admission: &WriteAdmission,
1887) -> Result<u32, SqliteError> {
1888    admission.check()?;
1889    conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
1890    require_autocommit(conn, "migration-tracking bootstrap")?;
1891
1892    let current_version: u32 = read_schema_version(conn)?;
1893
1894    // A database whose recorded version is ahead of the latest known migration
1895    // predates the consolidated V1 baseline (ADR-015) — e.g. it still carries the
1896    // pre-consolidation V2..V22 ledger — or was written by a newer build. Either
1897    // way the baseline schema would be silently skipped, leaving the process on a
1898    // stale schema. Fail loudly instead of corrupting silently.
1899    let latest_version = latest_schema_version();
1900    if current_version > latest_version {
1901        return Err(SqliteError::InvalidData(format!(
1902            "database schema version {current_version} is ahead of the latest known migration \
1903             {latest_version}. This database predates the consolidated baseline (ADR-015) or was \
1904             written by a newer build. Recreate it from the current schema; in-place downgrade is \
1905             not supported."
1906        )));
1907    }
1908
1909    // Every writable upgrade starts from an exact canonical version sequence;
1910    // fail before applying new migrations if MAX(version) hides a missing or
1911    // foreign row. A pre-V19 database may still carry the V13/V14 name
1912    // divergence that V19 exists to repair. Name validation therefore permits
1913    // exactly those two rows before V19, while every unrelated mismatch still
1914    // fails before any new migration is applied.
1915    let applied = read_applied_migration_ledger(conn, current_version)?;
1916    validate_applied_migration_versions(&applied, current_version)?;
1917    validate_applied_migration_names(&applied, current_version, current_version < 19)?;
1918
1919    Ok(current_version)
1920}
1921
1922/// Apply one versioned migration in its own IMMEDIATE transaction, probing
1923/// capacity after BEGIN and before its first statement (ADR-154 section 4).
1924fn apply_versioned_migration(
1925    conn: &mut Connection,
1926    migration: &VersionedMigration,
1927    admission: &WriteAdmission,
1928    latest_version: u32,
1929) -> Result<MigrationStep, SqliteError> {
1930    // IMMEDIATE: take the write lock up front so concurrent boots serialize
1931    // here instead of failing mid-migration when a DEFERRED transaction
1932    // upgrades to a write.
1933    #[cfg(test)]
1934    let instrumented_first_begin =
1935        test_sync::PARTICIPATE.with(|p| p.get()) && !test_sync::FIRST_BEGIN_DONE.with(|f| f.get());
1936    #[cfg(test)]
1937    if instrumented_first_begin {
1938        test_sync::FIRST_BEGIN_DONE.with(|f| f.set(true));
1939    }
1940    // Replaces the busy_timeout raised for this unit on a participating test
1941    // connection: records SQLite-observed contention, then keeps retrying.
1942    #[cfg(test)]
1943    if test_sync::PARTICIPATE.with(|p| p.get()) {
1944        conn.busy_handler(Some(test_sync::record_busy))?;
1945    }
1946    let tx = conn
1947        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1948        .map_err(|e| SqliteError::Migration {
1949            version: migration.version,
1950            error: e.to_string(),
1951        })?;
1952    if let Err(error) = admission.check() {
1953        let rollback = tx.rollback();
1954        return Err(capacity_refusal_after_rollback(
1955            conn,
1956            rollback,
1957            error,
1958            "core schema migration",
1959        ));
1960    }
1961
1962    // Re-check under the write lock: a sibling process may have applied
1963    // this migration (and possibly later ones) while we waited. Running
1964    // its DDL again would fail; fast-forward past everything it applied.
1965    let sibling_version: u32 = tx
1966        .query_row(SCHEMA_VERSION_PROBE_SQL, [], |row| row.get(0))
1967        .map_err(|e| SqliteError::Migration {
1968            version: migration.version,
1969            error: e.to_string(),
1970        })?;
1971    #[cfg(test)]
1972    if instrumented_first_begin {
1973        use std::sync::atomic::Ordering::SeqCst;
1974        if sibling_version == 0 {
1975            // Winner: hold the write lock until SQLite has reported a
1976            // busy acquisition to the loser (its busy handler fired) —
1977            // proof the loser's BEGIN is actually blocked on this held
1978            // lock, not merely intended. Bounded so a regression fails
1979            // the assertion instead of hanging the test.
1980            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1981            while !test_sync::BUSY_OBSERVED.load(SeqCst) && std::time::Instant::now() < deadline {
1982                std::thread::yield_now();
1983            }
1984        } else {
1985            // Loser: our first BEGIN just returned. Record whether the
1986            // winner had already committed — true means we blocked across
1987            // its held lock.
1988            test_sync::LOSER_SAW_WINNER_COMMIT
1989                .store(test_sync::WINNER_COMMITTED.load(SeqCst), SeqCst);
1990        }
1991    }
1992
1993    // The ahead-of-latest guard above ran on a pre-lock read; a newer
1994    // build may have committed a version past ours while we waited for
1995    // the write lock. Accepting it (clamped) would return Ok on a schema
1996    // this binary does not understand — reject it the same way.
1997    if sibling_version > latest_version {
1998        return Err(SqliteError::InvalidData(format!(
1999            "database schema version {sibling_version} is ahead of the latest known \
2000             migration {latest_version} (committed by a concurrent process while this \
2001             one waited for the migration write lock). This build cannot run against \
2002             the newer schema; upgrade the binary or recreate the database."
2003        )));
2004    }
2005
2006    if sibling_version >= migration.version {
2007        #[cfg(test)]
2008        test_sync::LOCKED_FAST_FORWARDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2009        return Ok(MigrationStep::AppliedBySibling(sibling_version));
2010    }
2011
2012    if migration.version == 46 {
2013        memory_visibility::capture_pre_v46(&tx).map_err(|error| SqliteError::Migration {
2014            version: migration.version,
2015            error: error.to_string(),
2016        })?;
2017        #[cfg(test)]
2018        memory_visibility::test_state::stop_at(memory_visibility::test_state::Stop::AfterCapture)?;
2019    }
2020
2021    if migration.version == ATTACHMENT_CUTOVER_VERSION {
2022        let status = attachment_cutover_status(&tx).map_err(|e| SqliteError::Migration {
2023            version: migration.version,
2024            error: e.to_string(),
2025        })?;
2026        let legacy_refs: i64 = tx
2027            .query_row(
2028                "SELECT COUNT(*) FROM entities WHERE content_ref IS NOT NULL",
2029                [],
2030                |row| row.get(0),
2031            )
2032            .map_err(|e| SqliteError::Migration {
2033                version: migration.version,
2034                error: e.to_string(),
2035            })?;
2036
2037        // V21 belongs to the boot coordinator. Ordinary backend open may
2038        // finish the degenerate zero-ref case atomically, but it must not
2039        // expose a dual-source interval or eagerly stage a legacy DB.
2040        if status == AttachmentCutoverStatus::Incomplete || legacy_refs != 0 {
2041            drop(tx);
2042            return Ok(MigrationStep::Deferred);
2043        }
2044        if status != AttachmentCutoverStatus::Pending {
2045            return Err(SqliteError::Migration {
2046                version: migration.version,
2047                error: format!("unexpected attachment cutover state {status:?}"),
2048            });
2049        }
2050
2051        let now = chrono::Utc::now().timestamp_micros();
2052        stage_attachment_cutover_on_connection(&tx, now).map_err(|e| SqliteError::Migration {
2053            version: migration.version,
2054            error: e.to_string(),
2055        })?;
2056        finalize_attachment_cutover_on_connection(&tx, now).map_err(|e| {
2057            SqliteError::Migration {
2058                version: migration.version,
2059                error: e.to_string(),
2060            }
2061        })?;
2062    } else if migration.version == 36 {
2063        // The events store DDL may already have added these columns.
2064        crate::stores::event::ensure_operation_attribution_columns(&tx).map_err(|error| {
2065            SqliteError::Migration {
2066                version: migration.version,
2067                error: error.to_string(),
2068            }
2069        })?;
2070    } else if migration.version == 44 {
2071        migrate_outbound_due_key(&tx).map_err(|error| SqliteError::Migration {
2072            version: migration.version,
2073            error: error.to_string(),
2074        })?;
2075    } else if migration.version == 48 {
2076        migrate_acknowledgement_journal(&tx).map_err(|error| SqliteError::Migration {
2077            version: migration.version,
2078            error: error.to_string(),
2079        })?;
2080    } else if migration.name == SESSION_IDENTITY_MIGRATION_NAME {
2081        tx.execute_batch(migration.up)
2082            .map_err(|error| SqliteError::Migration {
2083                version: migration.version,
2084                error: error.to_string(),
2085            })?;
2086        session_identity_migration::apply(&tx).map_err(|error| SqliteError::Migration {
2087            version: migration.version,
2088            error: error.to_string(),
2089        })?;
2090    } else {
2091        tx.execute_batch(migration.up)
2092            .map_err(|e| SqliteError::Migration {
2093                version: migration.version,
2094                error: e.to_string(),
2095            })?;
2096    }
2097
2098    let visibility_counts = if migration.version == MEMORY_VISIBILITY_CUTOVER_VERSION {
2099        Some(
2100            memory_visibility::cutover_counts(&tx).map_err(|error| SqliteError::Migration {
2101                version: migration.version,
2102                error: error.to_string(),
2103            })?,
2104        )
2105    } else {
2106        None
2107    };
2108
2109    // V19's repair contract includes normalizing the two known-divergent
2110    // recorded names. `_schema_migrations` is created and owned by this
2111    // runner (not by any migration file), so the normalization lives
2112    // here, in the same transaction that applies V19's SQL. Exact,
2113    // closed set — versions 13 and 14 only; any other (version, name)
2114    // mismatch still fails startup via validate_applied_migration_ledger.
2115    if migration.version == 19 {
2116        tx.execute_batch(
2117            "UPDATE _schema_migrations SET name = 'list_cursor_sequences' WHERE version = 13;\n\
2118             UPDATE _schema_migrations SET name = 'graph_edges_id_unique' WHERE version = 14;",
2119        )
2120        .map_err(|e| SqliteError::Migration {
2121            version: migration.version,
2122            error: e.to_string(),
2123        })?;
2124    }
2125
2126    let now = chrono::Utc::now().timestamp_micros();
2127    tx.execute(
2128        "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3) \
2129         ON CONFLICT(version) DO NOTHING",
2130        rusqlite::params![migration.version, migration.name, now],
2131    )
2132    .map_err(|e| SqliteError::Migration {
2133        version: migration.version,
2134        error: e.to_string(),
2135    })?;
2136
2137    #[cfg(test)]
2138    if instrumented_first_begin {
2139        test_sync::WINNER_COMMITTED.store(true, std::sync::atomic::Ordering::SeqCst);
2140    }
2141
2142    #[cfg(test)]
2143    if migration.version == MEMORY_VISIBILITY_CUTOVER_VERSION {
2144        memory_visibility::test_state::stop_at(
2145            memory_visibility::test_state::Stop::BeforeCutoverCommit,
2146        )?;
2147    }
2148    tx.commit().map_err(|e| SqliteError::Migration {
2149        version: migration.version,
2150        error: e.to_string(),
2151    })?;
2152    if let Some(counts) = visibility_counts {
2153        memory_visibility::log_counts(&counts, conn.path().unwrap_or(":memory:"));
2154    }
2155    #[cfg(test)]
2156    if migration.version == 46 {
2157        memory_visibility::test_state::stop_at(
2158            memory_visibility::test_state::Stop::AfterV46Commit,
2159        )?;
2160    }
2161
2162    Ok(MigrationStep::Applied)
2163}
2164
2165#[derive(Debug)]
2166pub struct EmbeddingModelRegistryRecord {
2167    /// Vector engine name (e.g. `"paraphrase"`).
2168    pub engine_name: String,
2169    /// Model identifier (e.g. `"all-minilm-l6-v2"`).
2170    pub model_id: String,
2171    /// Canonical deduplication key combining engine and model.
2172    pub key_version: String,
2173    /// Embedding dimensionality.
2174    pub dimensions: u32,
2175    /// Lifecycle status (`"active"` or `"superseded"`).
2176    pub status: String,
2177    /// Epoch timestamp when the model was activated.
2178    pub activated_at: Option<i64>,
2179    /// Epoch timestamp when the model was superseded.
2180    pub superseded_at: Option<i64>,
2181}
2182
2183/// Query the `_embedding_models` registry.
2184///
2185/// Opens the database at `db` (defaults to `~/.khive/khive.db`) and
2186/// returns all registry rows, optionally filtered by `engine_name`.
2187/// Returns an empty vec if the database or table does not exist.
2188pub fn query_embedding_models(
2189    db: Option<&std::path::Path>,
2190    engine_filter: Option<&str>,
2191) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
2192    let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
2193        std::env::var("HOME")
2194            .map(std::path::PathBuf::from)
2195            .unwrap_or_else(|_| std::path::PathBuf::from("."))
2196            .join(".khive/khive.db")
2197    });
2198    if !path.exists() {
2199        return Ok(Vec::new());
2200    }
2201    let conn = Connection::open_with_flags(
2202        path,
2203        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
2204            | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
2205            | rusqlite::OpenFlags::SQLITE_OPEN_URI,
2206    )?;
2207    query_embedding_models_conn(&conn, engine_filter)
2208}
2209
2210/// Query `_embedding_models` from an existing connection (testable without a file).
2211///
2212/// Returns an empty vec if the table does not exist.
2213pub(crate) fn query_embedding_models_conn(
2214    conn: &Connection,
2215    engine_filter: Option<&str>,
2216) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
2217    let exists: bool = conn.query_row(
2218        "SELECT COUNT(*) > 0 FROM sqlite_master \
2219         WHERE type='table' AND name='_embedding_models'",
2220        [],
2221        |row| row.get(0),
2222    )?;
2223    if !exists {
2224        return Ok(Vec::new());
2225    }
2226
2227    let sql = if engine_filter.is_some() {
2228        "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
2229         FROM _embedding_models WHERE engine_name = ?1 \
2230         ORDER BY engine_name, activated_at IS NULL, activated_at"
2231    } else {
2232        "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
2233         FROM _embedding_models \
2234         ORDER BY engine_name, activated_at IS NULL, activated_at"
2235    };
2236    let mut stmt = conn.prepare(sql)?;
2237    let map_row = |row: &rusqlite::Row<'_>| {
2238        let dim_raw: i64 = row.get(3)?;
2239        let dimensions = u32::try_from(dim_raw).map_err(|_| {
2240            rusqlite::Error::FromSqlConversionFailure(
2241                3,
2242                rusqlite::types::Type::Integer,
2243                Box::new(std::io::Error::other(format!(
2244                    "_embedding_models.dim value {dim_raw} is outside the valid u32 range [0, {}]",
2245                    u32::MAX,
2246                ))),
2247            )
2248        })?;
2249        Ok(EmbeddingModelRegistryRecord {
2250            engine_name: row.get(0)?,
2251            model_id: row.get(1)?,
2252            key_version: row.get(2)?,
2253            dimensions,
2254            status: row.get(4)?,
2255            activated_at: row.get(5)?,
2256            superseded_at: row.get(6)?,
2257        })
2258    };
2259
2260    if let Some(engine) = engine_filter {
2261        stmt.query_map([engine], map_row)?
2262            .collect::<Result<Vec<_>, _>>()
2263            .map_err(Into::into)
2264    } else {
2265        stmt.query_map([], map_row)?
2266            .collect::<Result<Vec<_>, _>>()
2267            .map_err(Into::into)
2268    }
2269}
2270
2271// Test fixtures use the public policy constructor and a private lock namespace.
2272#[cfg(test)]
2273pub(crate) fn migration_test_policy() -> MigrationWritePolicy {
2274    MigrationWritePolicy::new(
2275        crate::DiskGuardEnvironment::capture()
2276            .resolve(None, None)
2277            .expect("test disk policy"),
2278        crate::PoolConfig::for_test()
2279            .volume_lock_dir
2280            .expect("test volume-lock directory"),
2281    )
2282    .expect("valid test migration policy")
2283}
2284
2285#[cfg(test)]
2286pub(crate) fn run_migrations_for_test(conn: &mut Connection) -> Result<u32, SqliteError> {
2287    run_migrations_with_policy(conn, &migration_test_policy())
2288}
2289
2290#[cfg(test)]
2291fn apply_schema_plan_for_test(
2292    conn: &mut Connection,
2293    plan: &ServiceSchemaPlan,
2294) -> Result<(), SqliteError> {
2295    apply_schema_plan_with_policy(conn, plan, &migration_test_policy())
2296}
2297
2298#[cfg(test)]
2299fn stage_attachment_cutover_for_test(conn: &mut Connection) -> Result<(), SqliteError> {
2300    stage_attachment_cutover_with_policy(conn, &migration_test_policy())
2301}
2302
2303#[cfg(test)]
2304fn finalize_attachment_cutover_for_test(conn: &mut Connection) -> Result<(), SqliteError> {
2305    finalize_attachment_cutover_with_policy(conn, &migration_test_policy())
2306}
2307
2308// =============================================================================
2309// Tests
2310// =============================================================================
2311
2312#[cfg(test)]
2313#[path = "entity_version_migration_measurement.rs"]
2314mod entity_version_measurement;
2315
2316#[cfg(test)]
2317#[path = "migrations_tests.rs"]
2318mod tests;
2319
2320#[cfg(test)]
2321#[path = "raw_migration_settlement_tests.rs"]
2322mod raw_settlement_tests;
2323
2324#[cfg(test)]
2325#[path = "git_note_index_migration_tests.rs"]
2326mod git_note_indexes;
2327
2328#[cfg(test)]
2329#[path = "entity_list_index_migration_tests.rs"]
2330mod entity_list_indexes;
2331
2332#[cfg(test)]
2333#[path = "schedule_core_index_migration_tests.rs"]
2334mod schedule_core_index_migration_tests;
2335
2336#[cfg(test)]
2337#[path = "comm_core_index_migration_tests.rs"]
2338mod comm_core_index_migration_tests;
2339
2340#[cfg(test)]
2341#[path = "knowledge_cursor_tag_migration_tests.rs"]
2342mod knowledge_cursor_tag_migration_tests;