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