crtx-store 0.1.1

SQLite persistence: migrations, repositories, transactions.
Documentation
//! Persistence layer for SQLite migrations and repositories.
#![warn(missing_docs)]

use std::path::Path;

use cortex_core::{CoreError, CoreResult};
use rusqlite::{Connection, OpenFlags};

pub mod migrate;
pub mod migrate_v2;
pub mod mirror;
pub mod proof;
pub mod repo;
pub mod semantic_diff;
pub mod verify;

pub use proof::{temporal_authority_proof_report, verify_memory_proof_closure};
pub use semantic_diff::{
    semantic_snapshot_from_store, ArtifactKind, ContradictionState, DoctrineForce, DoctrineState,
    KeyLifecycleState, KeyState, MemoryKind, MemoryState, PrincipalTrustState, PrincipleState,
    ProofState as SemanticProofState, RestoreDecision, RuntimeMode as SemanticRuntimeMode,
    SalienceDistribution, SalienceScore, SemanticChange, SemanticChangeKind, SemanticDiff,
    SemanticSeverity, SemanticSnapshot, TrustKeyState, TrustTier, TruthCeiling, TruthCeilingState,
    TruthState,
};

/// Initial SQLite schema for the MVP store (LANES T-2.A.1).
pub const INITIAL_MIGRATION_SQL: &str = include_str!("../migrations/001_init.sql");

/// Synchronous SQLite connection used by the store crate.
pub type Pool = Connection;

/// Crate-wide store result type.
pub type StoreResult<T> = Result<T, StoreError>;

/// Errors raised by the SQLite store boundary.
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
    /// SQLite operation failed.
    #[error("sqlite error: {0}")]
    Sqlite(#[from] rusqlite::Error),
    /// JSON encoding or decoding failed.
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    /// Timestamp parsing failed.
    #[error("time parse error: {0}")]
    Time(#[from] chrono::ParseError),
    /// Filesystem operation failed.
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    /// Core type validation failed.
    #[error("core error: {0}")]
    Core(#[from] CoreError),
    /// Store-specific validation failed.
    #[error("validation failed: {0}")]
    Validation(String),
    /// Post-migrate row-count verification refused because
    /// `pre.events.checked_add(SCHEMA_V1_TO_V2_EVENT_BOUNDARY_DELTA)` overflowed
    /// `u64`. A pre-migrate count at the saturation boundary is itself a bug;
    /// the verifier refuses honestly rather than masking the overflow with
    /// `saturating_add`. Surfaces stable invariant
    /// `verify.row_counts.checked_add_overflow` (RED_TEAM_FINDINGS phase B,
    /// finding B3).
    #[error(
        "verify.row_counts.checked_add_overflow: table {table} pre-migrate row count {pre} plus boundary-append delta {delta} overflows u64"
    )]
    RowCountCheckedAddOverflow {
        /// Counted table that overflowed (currently always `events`).
        table: &'static str,
        /// Pre-migrate row count from the backup manifest.
        pre: u64,
        /// Documented v1 -> v2 boundary-append delta that triggered overflow.
        delta: u64,
    },
}

impl StoreError {
    /// Returns the stable invariant name for this error variant when one is
    /// defined. Stable invariant names are how operators and tests match on
    /// refusal classes without parsing free text.
    #[must_use]
    pub fn invariant(&self) -> Option<&'static str> {
        match self {
            StoreError::RowCountCheckedAddOverflow { .. } => {
                Some(VERIFY_ROW_COUNTS_CHECKED_ADD_OVERFLOW_INVARIANT)
            }
            _ => None,
        }
    }
}

/// Stable invariant name surfaced by [`StoreError::RowCountCheckedAddOverflow`].
///
/// RED_TEAM_FINDINGS phase B, finding B3: a pre-migrate `events` count plus the
/// documented v1 -> v2 boundary-append delta that overflows `u64` is itself an
/// upstream bug. The verifier refuses with this stable invariant rather than
/// `saturating_add`-ing back to `u64::MAX` and silently agreeing with a store
/// that happens to share that count.
pub const VERIFY_ROW_COUNTS_CHECKED_ADD_OVERFLOW_INVARIANT: &str =
    "verify.row_counts.checked_add_overflow";

/// Opens the SQLite database under `data_dir` and applies pending migrations.
pub fn open(data_dir: &Path) -> StoreResult<Pool> {
    std::fs::create_dir_all(data_dir)?;
    let pool = Connection::open(data_dir.join("cortex.sqlite3"))?;
    verify_sqlite_load_extension_disabled(&pool)?;
    migrate::apply_pending(&pool)?;
    Ok(pool)
}

/// Returns SQLite compile-time options reported by the active library.
pub fn sqlite_compile_options(pool: &Pool) -> StoreResult<Vec<String>> {
    let mut stmt = pool.prepare("PRAGMA compile_options;")?;
    let options = stmt
        .query_map([], |row| row.get(0))?
        .collect::<Result<Vec<String>, _>>()?;
    Ok(options)
}

/// Fails closed when SQLite was compiled with loadable-extension support.
pub fn verify_sqlite_compile_options<I, S>(compile_options: I) -> StoreResult<()>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    for option in compile_options {
        if is_load_extension_enabled_compile_option(option.as_ref()) {
            return Err(StoreError::Validation(format!(
                "sqlite compile option {option} is forbidden; Cortex refuses SQLite builds with loadable extension support",
                option = option.as_ref()
            )));
        }
    }
    Ok(())
}

fn is_load_extension_enabled_compile_option(option: &str) -> bool {
    let normalized = option
        .trim()
        .strip_prefix("SQLITE_")
        .unwrap_or(option.trim());
    let key = normalized
        .split_once('=')
        .map_or(normalized, |(key, _)| key);
    key.eq_ignore_ascii_case("ENABLE_LOAD_EXTENSION")
}

/// Verifies SQL-level extension loading is not authorized on this connection.
///
/// The pinned bundled `libsqlite3-sys` build advertises
/// `ENABLE_LOAD_EXTENSION`, but `rusqlite` does not enable its
/// `load_extension` feature in this workspace and SQLite keeps SQL extension
/// loading disabled by default. Cortex checks that runtime state before
/// migrations instead of accepting compile options as proof of active authority.
pub fn verify_sqlite_load_extension_disabled(pool: &Pool) -> StoreResult<()> {
    match pool.query_row(
        "SELECT load_extension('cortex_extension_loading_must_remain_disabled');",
        [],
        |row| row.get::<_, String>(0),
    ) {
        Ok(_) => Err(StoreError::Validation(
            "sqlite load_extension unexpectedly executed".to_string(),
        )),
        Err(err) if sqlite_load_extension_is_disabled_error(&err) => Ok(()),
        Err(err) => Err(StoreError::Validation(format!(
            "sqlite load_extension preflight returned an unexpected error: {err}"
        ))),
    }
}

fn sqlite_load_extension_is_disabled_error(err: &rusqlite::Error) -> bool {
    let message = err.to_string().to_ascii_lowercase();
    message.contains("not authorized") || message.contains("no such function: load_extension")
}

/// Opens an existing SQLite database read-only.
///
/// Restore verification uses this to inspect candidate backup state without
/// applying migrations or otherwise changing the artifact under review.
pub fn open_existing_readonly(path: &Path) -> StoreResult<Pool> {
    Ok(Connection::open_with_flags(
        path,
        OpenFlags::SQLITE_OPEN_READ_ONLY,
    )?)
}

/// Compatibility stub retained for callers not yet moved to [`open`].
pub fn open_stub(data_dir: &Path) -> CoreResult<()> {
    open(data_dir).map_err(|err| CoreError::Validation(err.to_string()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn initial_migration_runs_cleanly_on_empty_db() {
        let pool = Connection::open_in_memory().expect("open in-memory sqlite");
        pool.execute_batch(INITIAL_MIGRATION_SQL)
            .expect("initial migration runs");

        let mut stmt = pool
            .prepare(
                "SELECT name FROM sqlite_master \
                 WHERE type = 'table' \
                 ORDER BY name;",
            )
            .expect("prepare table query");
        let actual: Vec<String> = stmt
            .query_map([], |row| row.get(0))
            .expect("list tables")
            .collect::<Result<_, _>>()
            .expect("read table rows");
        let expected = [
            "audit_records",
            "context_packs",
            "contradictions",
            "doctrine",
            "episodes",
            "events",
            "memories",
            "principles",
            "trace_events",
            "traces",
        ];
        assert_eq!(actual, expected);
    }
}