Skip to main content

mongreldb_core/
database.rs

1//! Multi-table `Database` container (spec §5, §6, §10).
2//!
3//! Owns the shared services — catalog, dual-counter epoch authority, shared
4//! raw/decoded page caches, snapshot-retention registry, and the DB-wide KEK —
5//! and mounts per-table [`Table`] engines under `tables/<id>/` that borrow them.
6//! P1 scope: per-table WALs remain (collapsed into one shared WAL in P2); the
7//! win here is one consistent commit clock across tables and one reopen path.
8
9use crate::catalog::{self, Catalog, CatalogEntry, TableState, META_DEK_LEN};
10use crate::engine::{SharedCtx, Table};
11use crate::epoch::{Epoch, EpochAuthority, EpochGuard, MaintenanceReceipt, Snapshot};
12use crate::error::{MongrelError, Result};
13use crate::external_table::ExternalTableEntry;
14use crate::memtable::Value;
15use crate::procedure::{
16    ProcedureCallOutput, ProcedureCallResult, ProcedureCallRow, ProcedureCondition, ProcedureStep,
17    ProcedureValue, StoredProcedure,
18};
19use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
20use crate::rowid::RowId;
21use crate::schema::{AlterColumn, ColumnDef, Schema, TypeId};
22use crate::trigger::{
23    StoredTrigger, TriggerCondition, TriggerConfig, TriggerEntry, TriggerEvent, TriggerExpr,
24    TriggerRaiseAction, TriggerStep, TriggerTarget, TriggerTiming, TriggerValue,
25};
26use parking_lot::{Mutex, RwLock};
27use std::collections::{HashMap, HashSet, VecDeque};
28use std::io::{Read, Write};
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
31use std::sync::Arc;
32
33pub const TABLES_DIR: &str = "tables";
34pub const VTAB_DIR: &str = "_vtab";
35pub const META_DIR: &str = "_meta";
36pub const KEYS_FILENAME: &str = "keys";
37pub const HISTORY_RETENTION_FILENAME: &str = "history_retention";
38pub const CTAS_BUILD_TABLE_PREFIX: &str = "__mongreldb_ctas_build_";
39
40/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
41/// than any table. `u64::MAX` is never allocated to a real table (the catalog
42/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
43pub const WAL_TABLE_ID: u64 = u64::MAX;
44/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
45/// state instead of an ordinary table.
46pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
47
48fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
49    catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
50        MongrelError::Conflict("security catalog version space is exhausted".into())
51    })?;
52    Ok(())
53}
54
55type OpenLeaseId = u64;
56
57static DATABASE_OPEN_WAIT_COUNT: AtomicU64 = AtomicU64::new(0);
58static DATABASE_OPEN_FAILURE_COUNT: AtomicU64 = AtomicU64::new(0);
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct DatabaseOpenMetrics {
62    pub lock_waits: u64,
63    pub failures: u64,
64}
65
66#[derive(Clone, Debug, Eq, Hash, PartialEq)]
67enum DatabaseOpenKey {
68    IntendedPath(PathBuf),
69    FileIdentity(crate::durable_file::DurableFileIdentity),
70}
71
72#[derive(Debug)]
73enum ProcessOpenState {
74    Opening { lease_id: OpenLeaseId },
75    Open { lease_id: OpenLeaseId },
76    Closing { lease_id: OpenLeaseId },
77}
78
79impl ProcessOpenState {
80    fn lease_id(&self) -> OpenLeaseId {
81        match self {
82            Self::Opening { lease_id } | Self::Open { lease_id } | Self::Closing { lease_id } => {
83                *lease_id
84            }
85        }
86    }
87}
88
89#[derive(Default)]
90struct ProcessOpenRegistry {
91    next_lease_id: OpenLeaseId,
92    entries: HashMap<DatabaseOpenKey, ProcessOpenState>,
93}
94
95fn process_open_registry() -> &'static Mutex<ProcessOpenRegistry> {
96    static REGISTRY: std::sync::OnceLock<Mutex<ProcessOpenRegistry>> = std::sync::OnceLock::new();
97    REGISTRY.get_or_init(|| Mutex::new(ProcessOpenRegistry::default()))
98}
99
100fn same_process_locked(path: &Path) -> MongrelError {
101    MongrelError::DatabaseLocked {
102        path: path.to_path_buf(),
103        message: "database is already open in this process; reuse the existing Arc<Database>"
104            .into(),
105    }
106}
107
108struct OpenReservation {
109    lease_id: OpenLeaseId,
110    keys: Vec<DatabaseOpenKey>,
111    committed: bool,
112}
113
114impl OpenReservation {
115    fn acquire(key: DatabaseOpenKey, display_path: &Path) -> Result<Self> {
116        let mut registry = process_open_registry().lock();
117        if registry.entries.contains_key(&key) {
118            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
119            return Err(same_process_locked(display_path));
120        }
121        registry.next_lease_id = registry.next_lease_id.checked_add(1).ok_or_else(|| {
122            MongrelError::Full("process database-open lease namespace exhausted".into())
123        })?;
124        let lease_id = registry.next_lease_id;
125        registry
126            .entries
127            .insert(key.clone(), ProcessOpenState::Opening { lease_id });
128        Ok(Self {
129            lease_id,
130            keys: vec![key],
131            committed: false,
132        })
133    }
134
135    fn into_lease(
136        mut self,
137        bootstrap_file: std::fs::File,
138        canonical_path: PathBuf,
139    ) -> ExclusiveDatabaseLease {
140        self.committed = true;
141        ExclusiveDatabaseLease {
142            lease_id: self.lease_id,
143            keys: std::mem::take(&mut self.keys),
144            bootstrap_file,
145            legacy_file: None,
146            canonical_path,
147            durable_root: None,
148            owner_pid: std::process::id(),
149            opened: false,
150        }
151    }
152}
153
154impl Drop for OpenReservation {
155    fn drop(&mut self) {
156        if self.committed {
157            return;
158        }
159        DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
160        let mut registry = process_open_registry().lock();
161        for key in &self.keys {
162            if registry
163                .entries
164                .get(key)
165                .is_some_and(|state| state.lease_id() == self.lease_id)
166            {
167                registry.entries.remove(key);
168            }
169        }
170    }
171}
172
173struct ExclusiveDatabaseLease {
174    lease_id: OpenLeaseId,
175    keys: Vec<DatabaseOpenKey>,
176    bootstrap_file: std::fs::File,
177    legacy_file: Option<std::fs::File>,
178    canonical_path: PathBuf,
179    durable_root: Option<Arc<crate::durable_file::DurableRoot>>,
180    owner_pid: u32,
181    opened: bool,
182}
183
184impl ExclusiveDatabaseLease {
185    fn claim_root_identity(&mut self, root: &crate::durable_file::DurableRoot) -> Result<()> {
186        let key = DatabaseOpenKey::FileIdentity(root.file_identity()?);
187        if self.keys.contains(&key) {
188            return Ok(());
189        }
190        let mut registry = process_open_registry().lock();
191        if registry.entries.contains_key(&key) {
192            return Err(same_process_locked(&self.canonical_path));
193        }
194        registry.entries.insert(
195            key.clone(),
196            ProcessOpenState::Opening {
197                lease_id: self.lease_id,
198            },
199        );
200        self.keys.push(key);
201        Ok(())
202    }
203
204    fn mark_open(&mut self) -> Result<()> {
205        let mut registry = process_open_registry().lock();
206        if self.keys.iter().any(|key| {
207            registry
208                .entries
209                .get(key)
210                .is_none_or(|state| state.lease_id() != self.lease_id)
211        }) {
212            return Err(MongrelError::Conflict(
213                "database-open reservation changed during initialization".into(),
214            ));
215        }
216        for key in &self.keys {
217            registry.entries.insert(
218                key.clone(),
219                ProcessOpenState::Open {
220                    lease_id: self.lease_id,
221                },
222            );
223        }
224        self.opened = true;
225        Ok(())
226    }
227}
228
229impl Drop for ExclusiveDatabaseLease {
230    fn drop(&mut self) {
231        if std::process::id() != self.owner_pid {
232            return;
233        }
234        if !self.opened {
235            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
236        }
237        {
238            let mut registry = process_open_registry().lock();
239            for key in &self.keys {
240                if registry
241                    .entries
242                    .get(key)
243                    .is_some_and(|state| state.lease_id() == self.lease_id)
244                {
245                    registry.entries.insert(
246                        key.clone(),
247                        ProcessOpenState::Closing {
248                            lease_id: self.lease_id,
249                        },
250                    );
251                }
252            }
253        }
254        if let Some(file) = &self.legacy_file {
255            let _ = fs2::FileExt::unlock(file);
256        }
257        let _ = fs2::FileExt::unlock(&self.bootstrap_file);
258        let mut registry = process_open_registry().lock();
259        for key in &self.keys {
260            if registry
261                .entries
262                .get(key)
263                .is_some_and(|state| state.lease_id() == self.lease_id)
264            {
265                registry.entries.remove(key);
266            }
267        }
268    }
269}
270
271fn commit_prepare_checkpoint(
272    control: Option<&crate::ExecutionControl>,
273    index: usize,
274) -> Result<()> {
275    if index.is_multiple_of(256) {
276        if let Some(control) = control {
277            control.checkpoint()?;
278        }
279    }
280    Ok(())
281}
282
283fn finish_controlled_commit_attempt(
284    result: Result<Epoch>,
285    after_commit: &mut Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
286) -> Result<Epoch> {
287    let Some(after_commit) = after_commit.as_mut() else {
288        return result;
289    };
290    match result {
291        Ok(epoch) => match (**after_commit)(Some(epoch)) {
292            Ok(()) => Ok(epoch),
293            Err(error) => Err(MongrelError::DurableCommit {
294                epoch: epoch.0,
295                message: error.to_string(),
296            }),
297        },
298        Err(MongrelError::DurableCommit { epoch, message }) => {
299            let callback_error = (**after_commit)(Some(Epoch(epoch))).err();
300            Err(MongrelError::DurableCommit {
301                epoch,
302                message: callback_error
303                    .map(|error| format!("{message}; commit callback: {error}"))
304                    .unwrap_or(message),
305            })
306        }
307        Err(error) => match (**after_commit)(None) {
308            Ok(()) => Err(error),
309            Err(callback_error) => Err(MongrelError::Other(format!(
310                "{error}; commit callback: {callback_error}"
311            ))),
312        },
313    }
314}
315
316fn current_unix_nanos() -> u64 {
317    std::time::SystemTime::now()
318        .duration_since(std::time::UNIX_EPOCH)
319        .unwrap_or_default()
320        .as_nanos() as u64
321}
322
323fn read_encryption_salt(
324    root: &crate::durable_file::DurableRoot,
325) -> Result<[u8; crate::encryption::SALT_LEN]> {
326    let mut file = root
327        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
328        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
329    let length = file.metadata()?.len();
330    if length != crate::encryption::SALT_LEN as u64 {
331        return Err(MongrelError::Encryption(format!(
332            "invalid encryption salt length: got {length}, expected {}",
333            crate::encryption::SALT_LEN
334        )));
335    }
336    let mut salt = [0_u8; crate::encryption::SALT_LEN];
337    file.read_exact(&mut salt)?;
338    Ok(salt)
339}
340
341fn incremental_aggregate_cache_key(
342    table: &str,
343    conditions: &[crate::query::Condition],
344    column: Option<u16>,
345    agg: crate::engine::NativeAgg,
346    principal: Option<&crate::auth::Principal>,
347    security_version: u64,
348) -> u64 {
349    use std::hash::{Hash, Hasher};
350    let projection = column.as_ref().map(std::slice::from_ref);
351    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
352    let mut hasher = std::collections::hash_map::DefaultHasher::new();
353    table.hash(&mut hasher);
354    query_key.hash(&mut hasher);
355    match agg {
356        crate::engine::NativeAgg::Count => 0u8,
357        crate::engine::NativeAgg::Sum => 1,
358        crate::engine::NativeAgg::Min => 2,
359        crate::engine::NativeAgg::Max => 3,
360        crate::engine::NativeAgg::Avg => 4,
361    }
362    .hash(&mut hasher);
363    if let Some(principal) = principal {
364        principal.user_id.hash(&mut hasher);
365        principal.created_epoch.hash(&mut hasher);
366        principal.username.hash(&mut hasher);
367        principal.is_admin.hash(&mut hasher);
368        let mut roles = principal.roles.clone();
369        roles.sort_unstable();
370        roles.hash(&mut hasher);
371    }
372    hasher.finish()
373}
374
375fn read_history_retention(
376    root: &crate::durable_file::DurableRoot,
377    current_epoch: Epoch,
378) -> Result<(u64, Epoch)> {
379    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
380    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
381        Ok(file) => file,
382        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
383            return Ok((0, current_epoch));
384        }
385        Err(error) => return Err(error.into()),
386    };
387    let length = file.metadata()?.len();
388    if length > MAX_HISTORY_RETENTION_BYTES {
389        return Err(MongrelError::ResourceLimitExceeded {
390            resource: "history retention bytes",
391            requested: usize::try_from(length).unwrap_or(usize::MAX),
392            limit: MAX_HISTORY_RETENTION_BYTES as usize,
393        });
394    }
395    let mut bytes = Vec::with_capacity(length as usize);
396    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
397        .read_to_end(&mut bytes)?;
398    if bytes.len() as u64 != length {
399        return Err(MongrelError::Other(
400            "history retention length changed while reading".into(),
401        ));
402    }
403    let text = std::str::from_utf8(&bytes)
404        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
405    let mut fields = text.split_whitespace();
406    let epochs = fields
407        .next()
408        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
409        .parse::<u64>()
410        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
411    let start = fields
412        .next()
413        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
414        .parse::<u64>()
415        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
416    if fields.next().is_some() || start > current_epoch.0 {
417        return Err(MongrelError::Other(
418            "history retention file has trailing fields or a future start epoch".into(),
419        ));
420    }
421    Ok((epochs, Epoch(start)))
422}
423
424fn write_history_retention<F>(
425    root: &Path,
426    epochs: u64,
427    start: Epoch,
428    after_publish: F,
429) -> Result<()>
430where
431    F: FnOnce(),
432{
433    let meta = root.join(META_DIR);
434    let path = meta.join(HISTORY_RETENTION_FILENAME);
435    let bytes = format!("{epochs} {}\n", start.0);
436    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
437    Ok(())
438}
439
440struct PreparedBackupDestination {
441    parent: crate::durable_file::DurableRoot,
442    destination_name: std::ffi::OsString,
443    destination_path: PathBuf,
444    stage_name: std::ffi::OsString,
445    stage: Option<Box<crate::durable_file::DurableRoot>>,
446}
447
448fn prepare_backup_destination(
449    source: &Path,
450    destination: &Path,
451) -> Result<PreparedBackupDestination> {
452    let destination_name = destination
453        .file_name()
454        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
455        .to_os_string();
456    let requested_parent = destination
457        .parent()
458        .filter(|path| !path.as_os_str().is_empty())
459        .unwrap_or_else(|| Path::new("."));
460    crate::durable_file::create_directory_all(requested_parent)?;
461    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
462    prepare_backup_destination_in(source, &parent, &destination_name)
463}
464
465fn prepare_backup_destination_in(
466    source: &Path,
467    parent: &crate::durable_file::DurableRoot,
468    destination_name: &std::ffi::OsStr,
469) -> Result<PreparedBackupDestination> {
470    let source = source.canonicalize()?;
471    if parent.canonical_path().starts_with(&source) {
472        return Err(MongrelError::InvalidArgument(
473            "backup destination must not be inside the source database".into(),
474        ));
475    }
476    if parent.entry_exists(Path::new(&destination_name))? {
477        return Err(MongrelError::Conflict(format!(
478            "backup destination already exists: {}",
479            parent.canonical_path().join(destination_name).display()
480        )));
481    }
482    let mut stage_name = None;
483    for _ in 0..128 {
484        let mut nonce = [0_u8; 8];
485        crate::encryption::fill_random(&mut nonce)?;
486        let suffix = nonce
487            .iter()
488            .map(|byte| format!("{byte:02x}"))
489            .collect::<String>();
490        let name = std::ffi::OsString::from(format!(
491            ".{}.backup-stage-{}-{suffix}",
492            destination_name.to_string_lossy(),
493            std::process::id()
494        ));
495        match parent.create_directory_new(Path::new(&name)) {
496            Ok(()) => {
497                stage_name = Some(name);
498                break;
499            }
500            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
501            Err(error) => return Err(error.into()),
502        }
503    }
504    let stage_name = stage_name
505        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
506    let stage = parent.open_directory(Path::new(&stage_name))?;
507    Ok(PreparedBackupDestination {
508        destination_path: parent.canonical_path().join(destination_name),
509        destination_name: destination_name.to_os_string(),
510        stage_name,
511        stage: Some(Box::new(stage)),
512        parent: parent.try_clone()?,
513    })
514}
515
516fn copy_backup_boundary(
517    source_root: &Path,
518    destination_root: &crate::durable_file::DurableRoot,
519    deferred_runs: &HashSet<PathBuf>,
520    copied: &mut Vec<PathBuf>,
521    control: Option<&crate::ExecutionControl>,
522) -> Result<()> {
523    let mut visited = 0;
524    crate::durable_file::walk_regular_files_nofollow(
525        source_root,
526        |relative, is_directory| {
527            if visited % 256 == 0 {
528                if let Some(control) = control {
529                    control.checkpoint()?;
530                }
531            }
532            visited += 1;
533            if backup_path_excluded(relative) {
534                return Ok(false);
535            }
536            if is_directory {
537                return Ok(true);
538            }
539            if deferred_runs.contains(relative) {
540                return Ok(false);
541            }
542            Ok(!(relative
543                .parent()
544                .and_then(Path::file_name)
545                .is_some_and(|parent| parent == "_runs")
546                && relative
547                    .extension()
548                    .is_some_and(|extension| extension == "sr")))
549        },
550        |relative| {
551            destination_root.create_directory_all(relative)?;
552            Ok(())
553        },
554        |relative, source| {
555            destination_root.copy_new_from(relative, source)?;
556            copied.push(relative.to_path_buf());
557            Ok(())
558        },
559    )
560}
561
562fn backup_path_excluded(relative: &Path) -> bool {
563    relative == Path::new("_meta/.lock")
564        || relative == Path::new("_meta/replica")
565        || relative == Path::new("_meta/repl_epoch")
566        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
567        || relative.components().any(|component| {
568            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
569        })
570}
571
572#[derive(Debug, Clone)]
573pub enum ExternalTriggerWrite {
574    Insert {
575        table: String,
576        cells: Vec<(u16, Value)>,
577    },
578    UpdateByPk {
579        table: String,
580        pk: Value,
581        cells: Vec<(u16, Value)>,
582    },
583    DeleteByPk {
584        table: String,
585        pk: Value,
586    },
587}
588
589impl ExternalTriggerWrite {
590    fn table(&self) -> &str {
591        match self {
592            Self::Insert { table, .. }
593            | Self::UpdateByPk { table, .. }
594            | Self::DeleteByPk { table, .. } => table,
595        }
596    }
597}
598
599#[derive(Debug, Clone, PartialEq)]
600pub enum ExternalTriggerBaseWrite {
601    Put {
602        table: String,
603        cells: Vec<(u16, Value)>,
604    },
605    Delete {
606        table: String,
607        row_id: RowId,
608    },
609}
610
611#[derive(Debug, Clone, PartialEq)]
612pub struct ExternalTriggerWriteResult {
613    pub state: Vec<u8>,
614    pub base_writes: Vec<ExternalTriggerBaseWrite>,
615}
616
617impl ExternalTriggerWriteResult {
618    pub fn new(state: Vec<u8>) -> Self {
619        Self {
620            state,
621            base_writes: Vec::new(),
622        }
623    }
624}
625
626pub trait ExternalTriggerBridge: Send + Sync {
627    fn apply_trigger_external_write(
628        &self,
629        entry: &ExternalTableEntry,
630        base_state: Vec<u8>,
631        op: ExternalTriggerWrite,
632    ) -> Result<ExternalTriggerWriteResult>;
633}
634
635/// A pending uniform-epoch run written during a large transaction (spec §8.5).
636struct SpilledRun {
637    table_id: u64,
638    run_id: u128,
639    pending_path: PathBuf,
640    final_path: PathBuf,
641    rows: Vec<crate::memtable::Row>,
642    row_count: u64,
643    min_rid: u64,
644    max_rid: u64,
645    content_hash: [u8; 32],
646}
647
648const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
649const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
650
651fn encode_spilled_row_chunks(
652    rows: &[crate::memtable::Row],
653    total_bytes: &mut usize,
654    total_limit: usize,
655    control: Option<&crate::ExecutionControl>,
656) -> Result<Vec<Vec<u8>>> {
657    let mut output = Vec::new();
658    let mut start = 0;
659    while start < rows.len() {
660        // Bincode's sequence length prefix is a u64 with the workspace's
661        // fixed-int options. `serialized_size` computes exact row sizes
662        // without first allocating one transaction-sized buffer.
663        let mut estimated_bytes = std::mem::size_of::<u64>();
664        let mut end = start;
665        while end < rows.len() {
666            if end % 256 == 0 {
667                if let Some(control) = control {
668                    control.checkpoint()?;
669                }
670            }
671            let row_bytes =
672                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
673                    MongrelError::ResourceLimitExceeded {
674                        resource: "spilled WAL row bytes",
675                        requested: usize::MAX,
676                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
677                    }
678                })?;
679            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
680                MongrelError::ResourceLimitExceeded {
681                    resource: "spilled WAL row bytes",
682                    requested: usize::MAX,
683                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
684                },
685            )?;
686            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
687                break;
688            }
689            estimated_bytes = next_bytes;
690            end += 1;
691        }
692        if end == start {
693            return Err(MongrelError::ResourceLimitExceeded {
694                resource: "spilled WAL row bytes",
695                requested: estimated_bytes.saturating_add(1),
696                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
697            });
698        }
699        let payload = bincode::serialize(&rows[start..end])?;
700        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
701            return Err(MongrelError::ResourceLimitExceeded {
702                resource: "spilled WAL row bytes",
703                requested: payload.len(),
704                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
705            });
706        }
707        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
708        if requested > total_limit {
709            return Err(MongrelError::ResourceLimitExceeded {
710                resource: "spilled WAL transaction bytes",
711                requested,
712                limit: total_limit,
713            });
714        }
715        *total_bytes = requested;
716        output.push(payload);
717        start = end;
718    }
719    Ok(output)
720}
721
722#[cfg(test)]
723mod spilled_wal_encoding_tests {
724    use super::*;
725
726    #[test]
727    fn logical_spill_payload_has_a_total_bound() {
728        let rows = (0..4)
729            .map(|row_id| crate::memtable::Row {
730                row_id: crate::rowid::RowId(row_id),
731                committed_epoch: Epoch::ZERO,
732                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
733                deleted: false,
734            })
735            .collect::<Vec<_>>();
736        let mut total = 0;
737        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
738        assert!(matches!(
739            error,
740            MongrelError::ResourceLimitExceeded {
741                resource: "spilled WAL transaction bytes",
742                ..
743            }
744        ));
745    }
746}
747
748/// Move spill files to their final names before the WAL commit. Dropping this
749/// guard restores pending names while commit is still known not to have begun.
750/// It is disarmed immediately before the first WAL append, where the outcome
751/// can become ambiguous and recovery may need the final names.
752struct PreparedRunLinks {
753    links: Vec<(PathBuf, PathBuf)>,
754    armed: bool,
755}
756
757impl PreparedRunLinks {
758    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
759        let mut guard = Self {
760            links: Vec::with_capacity(spilled.len()),
761            armed: true,
762        };
763        for run in spilled {
764            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
765            guard
766                .links
767                .push((run.pending_path.clone(), run.final_path.clone()));
768        }
769        Ok(guard)
770    }
771
772    fn disarm(&mut self) {
773        self.armed = false;
774        for (pending, _) in &self.links {
775            if let Some(parent) = pending.parent() {
776                let _ = std::fs::remove_dir_all(parent);
777            }
778        }
779    }
780}
781
782impl Drop for PreparedRunLinks {
783    fn drop(&mut self) {
784        if !self.armed {
785            return;
786        }
787        for (pending, final_path) in self.links.iter().rev() {
788            let _ = std::fs::rename(final_path, pending);
789        }
790    }
791}
792
793struct TableApplyBatch {
794    table_id: u64,
795    handle: TableHandle,
796    ops: Vec<crate::txn::StagedOp>,
797}
798
799#[derive(Debug, Clone)]
800struct TriggerRowImage {
801    columns: HashMap<u16, Value>,
802}
803
804impl TriggerRowImage {
805    fn from_row(row: crate::memtable::Row) -> Self {
806        Self {
807            columns: row.columns,
808        }
809    }
810
811    fn from_cells(cells: &[(u16, Value)]) -> Self {
812        Self {
813            columns: cells.iter().cloned().collect(),
814        }
815    }
816}
817
818#[derive(Debug, Clone)]
819struct WriteEvent {
820    table: String,
821    kind: TriggerEvent,
822    old: Option<TriggerRowImage>,
823    new: Option<TriggerRowImage>,
824    changed_columns: Vec<u16>,
825    op_indices: Vec<usize>,
826    put_idx: Option<usize>,
827    trigger_stack: Vec<String>,
828}
829
830#[derive(Default)]
831struct TriggerExpansion {
832    before: Vec<(u64, crate::txn::Staged)>,
833    before_stacks: Vec<Vec<String>>,
834    before_external: Vec<ExternalTriggerWrite>,
835    after: Vec<(u64, crate::txn::Staged)>,
836    after_stacks: Vec<Vec<String>>,
837    after_external: Vec<ExternalTriggerWrite>,
838    ignored_indices: std::collections::BTreeSet<usize>,
839}
840
841#[derive(Clone, PartialEq)]
842struct TriggerCatalogBinding {
843    triggers: Vec<TriggerEntry>,
844    tables: Vec<(String, u64, u64)>,
845    external_tables: Vec<(String, u64, u64)>,
846}
847
848fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
849    let mut triggers = catalog
850        .triggers
851        .iter()
852        .filter(|entry| entry.trigger.enabled)
853        .cloned()
854        .collect::<Vec<_>>();
855    if triggers.is_empty() {
856        return None;
857    }
858    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
859    let mut tables = catalog
860        .tables
861        .iter()
862        .filter(|entry| matches!(entry.state, TableState::Live))
863        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
864        .collect::<Vec<_>>();
865    tables.sort_unstable();
866    let mut external_tables = catalog
867        .external_tables
868        .iter()
869        .map(|entry| {
870            (
871                entry.name.clone(),
872                entry.created_epoch,
873                entry.declared_schema.schema_id,
874            )
875        })
876        .collect::<Vec<_>>();
877    external_tables.sort_unstable();
878    Some(TriggerCatalogBinding {
879        triggers,
880        tables,
881        external_tables,
882    })
883}
884
885struct TriggerProgramOutput<'a> {
886    added: &'a mut Vec<(u64, crate::txn::Staged)>,
887    added_stacks: &'a mut Vec<Vec<String>>,
888    added_external: &'a mut Vec<ExternalTriggerWrite>,
889    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
890}
891
892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
893enum TriggerProgramOutcome {
894    Continue,
895    Ignore,
896}
897
898/// An integrity issue found by [`Database::check`] (spec §16).
899#[derive(Debug, Clone)]
900pub struct CheckIssue {
901    pub table_id: u64,
902    pub table_name: String,
903    pub severity: String,
904    pub description: String,
905}
906
907/// One optimistic authorization snapshot for a complete scored read.
908#[derive(Debug, Clone)]
909pub struct AuthorizedReadSnapshot {
910    pub table: String,
911    pub table_snapshot: Snapshot,
912    pub data_generation: u64,
913    pub security_version: u64,
914    pub allowed_row_ids: Option<HashSet<RowId>>,
915}
916
917/// Exact table/security generation used by one successful authorized read.
918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
919pub struct AuthorizedReadStamp {
920    pub table_id: u64,
921    pub schema_id: u64,
922    pub data_generation: u64,
923    pub security_version: u64,
924    pub snapshot: Snapshot,
925}
926
927type RlsCacheKey = (String, u64, u64, String);
928
929/// Runtime statistics for the byte-bounded RLS candidate cache.
930#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
931pub struct RlsCacheStats {
932    pub entries: usize,
933    pub bytes: usize,
934    pub hits: u64,
935    pub misses: u64,
936    pub evictions: u64,
937    pub build_nanos: u64,
938    pub rows_evaluated: u64,
939}
940
941const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
942const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
943const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
944const CDC_MAX_EVENTS: usize = 100_000;
945const CDC_MAX_ROWS: usize = 1_000_000;
946const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
947const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
948
949fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
950    let requested = total.saturating_add(amount);
951    if requested > CDC_MAX_RETAINED_BYTES {
952        return Err(MongrelError::ResourceLimitExceeded {
953            resource,
954            requested,
955            limit: CDC_MAX_RETAINED_BYTES,
956        });
957    }
958    *total = requested;
959    Ok(())
960}
961
962fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
963    usize::try_from(row.estimated_bytes())
964        .unwrap_or(usize::MAX)
965        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
966}
967
968fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
969    let value_slot = std::mem::size_of::<serde_json::Value>();
970    row.columns.values().fold(512_usize, |bytes, value| {
971        let values = match value {
972            Value::Bytes(values) => values.len(),
973            Value::Json(values) => values.len(),
974            Value::Embedding(values) => values.len(),
975            _ => 1,
976        };
977        bytes.saturating_add(values.saturating_mul(value_slot))
978    })
979}
980
981fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
982    rows.iter().fold(0_usize, |bytes, row| {
983        bytes.saturating_add(cdc_row_json_bytes(row))
984    })
985}
986
987#[derive(Default)]
988struct RlsCache {
989    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
990    lru: VecDeque<RlsCacheKey>,
991    bytes: usize,
992    hits: u64,
993    misses: u64,
994    evictions: u64,
995    build_nanos: u64,
996    rows_evaluated: u64,
997}
998
999impl RlsCache {
1000    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
1001        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
1002        if value.is_some() {
1003            self.hits = self.hits.saturating_add(1);
1004            self.touch(key);
1005        } else {
1006            self.misses = self.misses.saturating_add(1);
1007        }
1008        value
1009    }
1010
1011    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
1012        let bytes = key
1013            .0
1014            .len()
1015            .saturating_add(key.3.len())
1016            .saturating_add(
1017                value
1018                    .capacity()
1019                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
1020            )
1021            .saturating_add(std::mem::size_of::<RlsCacheKey>());
1022        if bytes > RLS_CACHE_MAX_BYTES {
1023            return;
1024        }
1025        if let Some((_, old_bytes)) = self.entries.remove(&key) {
1026            self.bytes = self.bytes.saturating_sub(old_bytes);
1027        }
1028        self.lru.retain(|candidate| candidate != &key);
1029        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
1030            let Some(oldest) = self.lru.pop_front() else {
1031                break;
1032            };
1033            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
1034                self.bytes = self.bytes.saturating_sub(old_bytes);
1035                self.evictions = self.evictions.saturating_add(1);
1036            }
1037        }
1038        self.bytes = self.bytes.saturating_add(bytes);
1039        self.lru.push_back(key.clone());
1040        self.entries.insert(key, (value, bytes));
1041    }
1042
1043    fn touch(&mut self, key: &RlsCacheKey) {
1044        self.lru.retain(|candidate| candidate != key);
1045        self.lru.push_back(key.clone());
1046    }
1047
1048    fn stats(&self) -> RlsCacheStats {
1049        RlsCacheStats {
1050            entries: self.entries.len(),
1051            bytes: self.bytes,
1052            hits: self.hits,
1053            misses: self.misses,
1054            evictions: self.evictions,
1055            build_nanos: self.build_nanos,
1056            rows_evaluated: self.rows_evaluated,
1057        }
1058    }
1059}
1060
1061/// Mounted table with immutable, structurally shared scored-read generations.
1062#[derive(Clone)]
1063pub struct TableHandle {
1064    inner: TableHandleInner,
1065    generation_metrics: Arc<TableGenerationMetrics>,
1066}
1067
1068#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1069pub struct TableGenerationStats {
1070    pub active_read_generations: usize,
1071    pub max_live_read_generations: usize,
1072    pub cow_clone_count: u64,
1073    pub cow_clone_nanos: u64,
1074    pub estimated_cow_clone_bytes: u64,
1075    pub writer_wait_nanos: u64,
1076}
1077
1078#[derive(Default)]
1079#[doc(hidden)]
1080pub struct TableGenerationMetrics {
1081    active_read_generations: AtomicUsize,
1082    max_live_read_generations: AtomicUsize,
1083    cow_clone_count: AtomicU64,
1084    cow_clone_nanos: AtomicU64,
1085    estimated_cow_clone_bytes: AtomicU64,
1086    writer_wait_nanos: AtomicU64,
1087}
1088
1089impl TableGenerationMetrics {
1090    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
1091        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
1092        self.max_live_read_generations
1093            .fetch_max(active, Ordering::Relaxed);
1094        Arc::new(TableReadGeneration {
1095            table,
1096            metrics: Arc::clone(self),
1097        })
1098    }
1099
1100    fn stats(&self) -> TableGenerationStats {
1101        TableGenerationStats {
1102            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
1103            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
1104            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
1105            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
1106            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
1107            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
1108        }
1109    }
1110}
1111
1112/// Immutable, structurally shared snapshot used by scored readers.
1113pub struct TableReadGeneration {
1114    table: Table,
1115    metrics: Arc<TableGenerationMetrics>,
1116}
1117
1118impl std::ops::Deref for TableReadGeneration {
1119    type Target = Table;
1120
1121    fn deref(&self) -> &Self::Target {
1122        &self.table
1123    }
1124}
1125
1126impl Drop for TableReadGeneration {
1127    fn drop(&mut self) {
1128        self.metrics
1129            .active_read_generations
1130            .fetch_sub(1, Ordering::Relaxed);
1131    }
1132}
1133
1134#[derive(Clone)]
1135enum TableHandleInner {
1136    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
1137    Direct(Arc<Mutex<Table>>),
1138}
1139
1140pub enum TableGuard<'a> {
1141    CopyOnWrite {
1142        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
1143        metrics: Arc<TableGenerationMetrics>,
1144    },
1145    Direct {
1146        table: parking_lot::MutexGuard<'a, Table>,
1147    },
1148}
1149
1150impl TableHandle {
1151    fn new(table: Table) -> Self {
1152        Self {
1153            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
1154            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1155        }
1156    }
1157
1158    pub fn from_table(table: Table) -> Self {
1159        Self::new(table)
1160    }
1161
1162    pub fn lock(&self) -> TableGuard<'_> {
1163        let started = std::time::Instant::now();
1164        let guard = match &self.inner {
1165            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
1166                table: table.write(),
1167                metrics: Arc::clone(&self.generation_metrics),
1168            },
1169            TableHandleInner::Direct(table) => TableGuard::Direct {
1170                table: table.lock(),
1171            },
1172        };
1173        self.generation_metrics.writer_wait_nanos.fetch_add(
1174            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1175            Ordering::Relaxed,
1176        );
1177        guard
1178    }
1179
1180    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
1181        let started = std::time::Instant::now();
1182        let guard = match &self.inner {
1183            TableHandleInner::CopyOnWrite(table) => {
1184                table
1185                    .try_write_for(timeout)
1186                    .map(|table| TableGuard::CopyOnWrite {
1187                        table,
1188                        metrics: Arc::clone(&self.generation_metrics),
1189                    })
1190            }
1191            TableHandleInner::Direct(table) => table
1192                .try_lock_for(timeout)
1193                .map(|table| TableGuard::Direct { table }),
1194        };
1195        self.generation_metrics.writer_wait_nanos.fetch_add(
1196            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1197            Ordering::Relaxed,
1198        );
1199        guard
1200    }
1201
1202    pub fn ptr_eq(&self, other: &Self) -> bool {
1203        match (&self.inner, &other.inner) {
1204            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1205                Arc::ptr_eq(left, right)
1206            }
1207            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1208                Arc::ptr_eq(left, right)
1209            }
1210            _ => false,
1211        }
1212    }
1213
1214    pub fn read_generation_with_context(
1215        &self,
1216        context: Option<&crate::query::AiExecutionContext>,
1217    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1218        let mut table = if let Some(context) = context {
1219            loop {
1220                context.checkpoint()?;
1221                let wait = context
1222                    .remaining_duration()
1223                    .unwrap_or(std::time::Duration::from_millis(5))
1224                    .min(std::time::Duration::from_millis(5));
1225                if let Some(table) = self.try_lock_for(wait) {
1226                    break table;
1227                }
1228            }
1229        } else {
1230            self.lock()
1231        };
1232        let snapshot = table.snapshot();
1233        let generation = table.clone_read_generation()?;
1234        Ok((self.generation_metrics.activate(generation), snapshot))
1235    }
1236
1237    pub fn generation_stats(&self) -> TableGenerationStats {
1238        self.generation_metrics.stats()
1239    }
1240}
1241
1242impl From<Arc<Mutex<Table>>> for TableHandle {
1243    fn from(table: Arc<Mutex<Table>>) -> Self {
1244        Self {
1245            inner: TableHandleInner::Direct(table),
1246            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1247        }
1248    }
1249}
1250
1251impl std::ops::Deref for TableGuard<'_> {
1252    type Target = Table;
1253
1254    fn deref(&self) -> &Self::Target {
1255        match self {
1256            Self::CopyOnWrite { table, .. } => table.as_ref(),
1257            Self::Direct { table } => table,
1258        }
1259    }
1260}
1261
1262impl std::ops::DerefMut for TableGuard<'_> {
1263    fn deref_mut(&mut self) -> &mut Self::Target {
1264        match self {
1265            Self::CopyOnWrite { table, metrics } => {
1266                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1267                    let estimated_bytes = table.estimated_clone_bytes();
1268                    let started = std::time::Instant::now();
1269                    let table = Arc::make_mut(table);
1270                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1271                    metrics.cow_clone_nanos.fetch_add(
1272                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1273                        Ordering::Relaxed,
1274                    );
1275                    metrics
1276                        .estimated_cow_clone_bytes
1277                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1278                    table
1279                } else {
1280                    Arc::make_mut(table)
1281                }
1282            }
1283            Self::Direct { table } => table,
1284        }
1285    }
1286}
1287
1288#[derive(Clone, Debug)]
1289pub struct ReadAuthorization {
1290    pub operation: crate::auth::ColumnOperation,
1291    pub columns: Vec<u16>,
1292    pub permissions: Vec<crate::auth::Permission>,
1293}
1294
1295#[derive(Default, Debug)]
1296struct TableWritePermissionNeeds {
1297    insert: bool,
1298    insert_columns: Vec<u16>,
1299    update: bool,
1300    update_columns: Vec<u16>,
1301    delete: bool,
1302    truncate: bool,
1303}
1304
1305#[cfg(test)]
1306thread_local! {
1307    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1308    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1309    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1310    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1311    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1312    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1313}
1314
1315fn summarize_write_permissions(
1316    staging: &[(u64, crate::txn::Staged)],
1317) -> HashMap<u64, TableWritePermissionNeeds> {
1318    use crate::txn::Staged;
1319
1320    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1321    for (table_id, operation) in staging {
1322        let table = needs.entry(*table_id).or_default();
1323        match operation {
1324            Staged::Put(cells) => {
1325                table.insert = true;
1326                table
1327                    .insert_columns
1328                    .extend(cells.iter().map(|(column, _)| *column));
1329            }
1330            Staged::Update {
1331                changed_columns, ..
1332            } => {
1333                table.update = true;
1334                table.update_columns.extend(changed_columns);
1335            }
1336            Staged::Delete(_) => table.delete = true,
1337            Staged::Truncate => table.truncate = true,
1338        }
1339    }
1340    for table in needs.values_mut() {
1341        table.insert_columns.sort_unstable();
1342        table.insert_columns.dedup();
1343        table.update_columns.sort_unstable();
1344        table.update_columns.dedup();
1345    }
1346    needs
1347}
1348
1349struct SecurityCoordinator {
1350    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1351    gate: RwLock<()>,
1352    version: AtomicU64,
1353}
1354
1355fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1356    static COORDINATORS: std::sync::OnceLock<
1357        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1358    > = std::sync::OnceLock::new();
1359
1360    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1361    let mut coordinators = COORDINATORS
1362        .get_or_init(|| Mutex::new(HashMap::new()))
1363        .lock();
1364    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1365    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1366        return coordinator;
1367    }
1368    let coordinator = Arc::new(SecurityCoordinator {
1369        gate: RwLock::new(()),
1370        version: AtomicU64::new(version),
1371    });
1372    coordinators.insert(root, Arc::downgrade(&coordinator));
1373    coordinator
1374}
1375
1376pub fn lock_table_with_context<'a>(
1377    handle: &'a TableHandle,
1378    context: Option<&crate::query::AiExecutionContext>,
1379) -> Result<TableGuard<'a>> {
1380    let Some(context) = context else {
1381        return Ok(handle.lock());
1382    };
1383    loop {
1384        context.checkpoint()?;
1385        let wait = context
1386            .remaining_duration()
1387            .unwrap_or(std::time::Duration::from_millis(5))
1388            .min(std::time::Duration::from_millis(5));
1389        if let Some(guard) = handle.try_lock_for(wait) {
1390            return Ok(guard);
1391        }
1392    }
1393}
1394
1395/// Knobs for [`Database::open_with_options`].
1396///
1397/// All fields default to the same values the convenience
1398/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1399/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1400#[derive(Clone, Debug, Default)]
1401pub struct OpenOptions {
1402    /// Maximum time, in milliseconds, to wait for the cross-process database
1403    /// lock (`_meta/.lock`) before failing with `MongrelError::DatabaseLocked`.
1404    ///
1405    /// `0` (the default) preserves the historical fail-fast semantics: a
1406    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1407    /// `busy_timeout` semantics kick in once this is non-zero — the open
1408    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1409    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1410    /// point the open returns the same typed lock error as the fail-fast path.
1411    ///
1412    /// Only the cross-process lock is affected. Mounted tables, page-cache
1413    /// misses, and WAL appends already serialize through in-process locks
1414    /// that handle their own contention. A second independent open in the
1415    /// same process always returns `DatabaseLocked` immediately; share the
1416    /// existing `Arc<Database>` instead.
1417    pub lock_timeout_ms: u32,
1418    /// Total bytes the storage core's [`crate::memory::MemoryGovernor`] may
1419    /// hand out across every memory class (S1E-003). `None` (the default)
1420    /// uses [`DEFAULT_MEMORY_BUDGET_BYTES`]. The governor is a reservation
1421    /// accounting layer, not an allocation: this is a cap, not a preallocation.
1422    pub memory_budget_bytes: Option<u64>,
1423    /// Total bytes of live spill files the core's
1424    /// [`crate::spill::SpillManager`] allows across every query (S1E-004).
1425    /// `None` (the default) uses [`DEFAULT_TEMP_DISK_BUDGET_BYTES`]. Again a
1426    /// cap, not a preallocation.
1427    pub temp_disk_budget_bytes: Option<u64>,
1428    /// Open the database through the special offline-validation API (spec
1429    /// section 5.3): any [`crate::storage_mode::StorageMode`] — including
1430    /// `ClusterReplica`, which every normal open path rejects — is opened
1431    /// **read-only** so a backup validator can inspect it. The opened core
1432    /// rejects every write with [`MongrelError::ReadOnlyReplica`]. This is the
1433    /// only way to open a cluster replica outside the cluster node runtime.
1434    pub offline_validation: bool,
1435}
1436
1437impl OpenOptions {
1438    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1439    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1440    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1441        self.lock_timeout_ms = ms;
1442        self
1443    }
1444
1445    /// Set [`OpenOptions::memory_budget_bytes`].
1446    pub fn with_memory_budget_bytes(mut self, bytes: u64) -> Self {
1447        self.memory_budget_bytes = Some(bytes);
1448        self
1449    }
1450
1451    /// Set [`OpenOptions::temp_disk_budget_bytes`].
1452    pub fn with_temp_disk_budget_bytes(mut self, bytes: u64) -> Self {
1453        self.temp_disk_budget_bytes = Some(bytes);
1454        self
1455    }
1456
1457    /// Set [`OpenOptions::offline_validation`]. `true` opens any storage mode
1458    /// read-only (the offline backup-validator API of spec section 5.3).
1459    pub fn with_offline_validation(mut self, offline_validation: bool) -> Self {
1460        self.offline_validation = offline_validation;
1461        self
1462    }
1463}
1464
1465/// How an open/create path treats the durable storage-mode marker (spec
1466/// section 5.3, Stage 2E). Threaded from the public constructors through the
1467/// open chain into [`Database::finish_open`].
1468#[derive(Debug, Clone)]
1469pub(crate) enum OpenModeGate {
1470    /// Normal embedded/server open: `ClusterReplica` markers are rejected with
1471    /// [`crate::storage_mode::StorageModeError::ClusterReplicaRequiresClusterRuntime`].
1472    Normal,
1473    /// Offline backup validator: any mode opens, forced read-only.
1474    OfflineValidation,
1475    /// Cluster node runtime: the marker must exist and equal this
1476    /// `ClusterReplica` identity; the core opens read-only for user paths (all
1477    /// writes arrive through the replicated apply path).
1478    ClusterRuntime {
1479        /// Expected owning cluster.
1480        cluster_id: mongreldb_types::ids::ClusterId,
1481        /// Expected owning node.
1482        node_id: mongreldb_types::ids::NodeId,
1483        /// Expected replicated database.
1484        database_id: mongreldb_types::ids::DatabaseId,
1485    },
1486    /// Fresh create: no marker may exist yet; this mode is written.
1487    Create(crate::storage_mode::StorageMode),
1488}
1489
1490/// Default node-level memory budget (S1E-003): 1 GiB. Comfortably covers the
1491/// two 64 MiB caches with headroom for query execution, result buffering, and
1492/// maintenance reservations.
1493pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
1494
1495/// Default node-level temporary-disk (spill) budget (S1E-004): 4 GiB of live
1496/// spill files across every query on the core.
1497pub const DEFAULT_TEMP_DISK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
1498
1499/// Resolved node-level resource budgets for one storage core
1500/// (S1E-003/S1E-004), threaded from [`OpenOptions`] through the open chain
1501/// into [`Database::finish_open`].
1502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1503pub(crate) struct CoreResourceConfig {
1504    pub memory_budget_bytes: u64,
1505    pub temp_disk_budget_bytes: u64,
1506}
1507
1508impl Default for CoreResourceConfig {
1509    fn default() -> Self {
1510        Self {
1511            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
1512            temp_disk_budget_bytes: DEFAULT_TEMP_DISK_BUDGET_BYTES,
1513        }
1514    }
1515}
1516
1517impl CoreResourceConfig {
1518    fn from_options(options: &OpenOptions) -> Result<Self> {
1519        let config = Self {
1520            memory_budget_bytes: options
1521                .memory_budget_bytes
1522                .unwrap_or(DEFAULT_MEMORY_BUDGET_BYTES),
1523            temp_disk_budget_bytes: options
1524                .temp_disk_budget_bytes
1525                .unwrap_or(DEFAULT_TEMP_DISK_BUDGET_BYTES),
1526        };
1527        if config.memory_budget_bytes == 0 {
1528            return Err(MongrelError::InvalidArgument(
1529                "memory_budget_bytes must be nonzero".into(),
1530            ));
1531        }
1532        if config.temp_disk_budget_bytes == 0 {
1533            return Err(MongrelError::InvalidArgument(
1534                "temp_disk_budget_bytes must be nonzero".into(),
1535            ));
1536        }
1537        Ok(config)
1538    }
1539}
1540
1541/// Per-table version-retention pin diagnostics (S1C-004): one mounted table's
1542/// active pin sources as reported by [`crate::engine::Table::version_pins_report`].
1543#[derive(Debug, Clone)]
1544pub struct TablePinsReport {
1545    /// The mounted table's id.
1546    pub table_id: u64,
1547    /// The mounted table's catalog name.
1548    pub table: String,
1549    /// Every active version-retention pin source on the table.
1550    pub pins: crate::retention::PinsReport,
1551}
1552
1553/// The shared storage core (spec §10.1, S1A-001): one catalog, one epoch
1554/// clock, shared caches, a shared WAL, and a live map of name → `Arc<Table>`.
1555///
1556/// `DatabaseCore` owns every storage resource — the durable root, the
1557/// exclusive lease, the catalog, the commit log, the epoch authority, the
1558/// transaction state, the mounted tables, and the page caches — plus the
1559/// lifecycle state machine (S1A-004). It is shared through an `Arc`: the
1560/// exclusive [`Database`] owner holds one reference, and every
1561/// [`crate::handle::DatabaseHandle`] issued by
1562/// [`crate::manager::DatabaseManager`] holds another. The core deliberately
1563/// does **not** store one mutable "current principal" (spec §4.6): per-caller
1564/// identity lives on the handle types, not inside shared storage state.
1565pub struct DatabaseCore {
1566    root: PathBuf,
1567    durable_root: Arc<crate::durable_file::DurableRoot>,
1568    /// Lifecycle state machine (spec §10.1, S1A-004). Shared through an `Arc`
1569    /// so [`crate::core::OperationGuard`]s are `'static` and thread-safe.
1570    lifecycle: Arc<crate::core::LifecycleController>,
1571    /// Set by [`crate::manager::DatabaseManager`] when this core backs shared
1572    /// handles: lets `shutdown()` transition the registry entry through
1573    /// `Closing` to removal. `None` for exclusively-owned cores.
1574    registry: std::sync::OnceLock<crate::manager::CoreRegistration>,
1575    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1576    read_only: bool,
1577    catalog: RwLock<Catalog>,
1578    security_coordinator: Arc<SecurityCoordinator>,
1579    security_catalog_disk_reads: AtomicU64,
1580    rls_cache: Mutex<RlsCache>,
1581    epoch: Arc<EpochAuthority>,
1582    snapshots: Arc<SnapshotRegistry>,
1583    /// S1E-003: the node-level memory governor for this core. Exactly one per
1584    /// core; both caches below hold reservations under it and are registered
1585    /// as reclaimable subsystems it can evict under pressure (escalation
1586    /// step 2).
1587    memory_governor: crate::memory::MemoryGovernor,
1588    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1589    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1590    /// S1E-004: the node-level spill manager, rooted at `<db-root>/temp/spill`
1591    /// and sealed with the database meta DEK when encryption is enabled. Its
1592    /// startup sweep ran during open; per-query sessions draw from it.
1593    spill_manager: crate::spill::SpillManager,
1594    /// S1E-002: workload resource groups for admission (concurrency, memory,
1595    /// temp disk, work units). Seeded with class defaults at open; operators
1596    /// reconfigure through [`Database::resource_groups`].
1597    resource_groups: crate::resource::ResourceGroupRegistry,
1598    /// Optional pluggable embedding providers (local models / registered
1599    /// backends). Empty by default — dense ANN uses application-supplied
1600    /// vectors; sparse retrieval needs no provider.
1601    embedding_providers: crate::embedding::EmbeddingProviderRegistry,
1602    /// S1F-002: the durable job registry (sibling `JOBS` file). Exactly one
1603    /// per core; jobs recovered mid-`Running` by a crash come back `Paused`.
1604    job_registry: Arc<crate::jobs::JobRegistry>,
1605    commit_lock: Arc<Mutex<()>>,
1606    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1607    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1608    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1609    /// writes also land in this one WAL (B1 — one WAL per database).
1610    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1611    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1612    /// in P2.7; here it just needs to be unique within an open. Shared with
1613    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1614    next_txn_id: Arc<Mutex<u64>>,
1615    tables: RwLock<HashMap<u64, TableHandle>>,
1616    kek: Option<Arc<crate::encryption::Kek>>,
1617    /// Serializes DDL (create/drop table); data commits serialize through
1618    /// `commit_lock` shared via `SharedCtx`.
1619    ddl_lock: Mutex<()>,
1620    meta_dek: Option<[u8; META_DEK_LEN]>,
1621    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1622    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1623    spill_threshold: std::sync::atomic::AtomicU64,
1624    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1625    /// detection (spec §9.2).
1626    conflicts: crate::txn::ConflictIndex,
1627    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1628    /// pruning (spec §9.2, review fix #12).
1629    active_txns: crate::txn::ActiveTxns,
1630    /// S1B-003: the core's key/predicate lock manager. One per core; commits
1631    /// acquire unique-constraint key claims, FK parent-protection holds,
1632    /// sequence-allocation barriers, and the DML Shared hold on the schema
1633    /// barrier (DDL takes it Exclusive), all released when the transaction
1634    /// ends.
1635    lock_manager: Arc<crate::locks::LockManager>,
1636    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1637    /// Shared with mounted tables so a single-table commit also honors poison.
1638    poisoned: Arc<std::sync::atomic::AtomicBool>,
1639    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1640    /// but defers the fsync to one leader here, so concurrent commits share a
1641    /// single fsync (spec §9.3). Shared with mounted tables.
1642    group: Arc<crate::txn::GroupCommit>,
1643    /// FND-004 (spec §9.4): the standalone commit-log authority wrapping
1644    /// `shared_wal` + `group`. The commit sequencer routes WAL appends and
1645    /// group-commit durability through it, and reader-visibility publication
1646    /// (`publish_in_order`) is gated on its commit receipts.
1647    commit_log: Arc<crate::commit_log::StandaloneCommitLog>,
1648    /// The node's single HLC timestamp authority (spec §4.1, §8.2; ADR-0003).
1649    /// Transaction read timestamps are captured at `begin`, and commit
1650    /// timestamps are allocated here under the sequencer lock (strictly after
1651    /// every participant read/write timestamp). `Epoch` remains the
1652    /// reader-visibility counter during the dual-model migration.
1653    hlc: Arc<mongreldb_types::hlc::HlcClock>,
1654    /// S1B-005: durable idempotency ledger for keyed remote writes, persisted
1655    /// in the sibling `TXN_IDEMPOTENCY` file (mirroring `jobs.rs`'s `JOBS`).
1656    idempotency: crate::txn::IdempotencyLedger,
1657    /// Per-open epoch → commit-timestamp ledger backing
1658    /// [`Database::commit_ts_for_epoch`]. Commits sealed within this open
1659    /// record the exact receipt `HlcTimestamp`; commits recovered from the
1660    /// durable `Op::CommitTimestamp` WAL ledger at open reconstruct the
1661    /// physical component only (`logical`/`node_tiebreaker` are 0 — the
1662    /// ledger byte format stores micros). Bounded to the newest
1663    /// [`COMMIT_TS_LEDGER_CAP`] epochs.
1664    commit_ts_ledger: Mutex<std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp>>,
1665    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1666    /// live spill's pending dir (review fix #14, spec §6.4).
1667    active_spills: Arc<crate::retention::ActiveSpills>,
1668    /// A write lock captures a consistent bootstrap image; transaction commits
1669    /// hold a read lock across spill preparation, WAL append, and publish.
1670    replication_barrier: parking_lot::RwLock<()>,
1671    /// Number of rotated WAL segments retained for lagging followers.
1672    replication_wal_retention_segments: AtomicUsize,
1673    /// Live immutable run files used by online backups or scored read
1674    /// generations. GC cannot unlink them until every owning guard drops.
1675    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1676    /// Test-only barrier invoked after a transaction writes its spill runs but
1677    /// before the sequencer/publish, so tests can race `gc()` against an
1678    /// in-flight spill. `None` in production.
1679    #[doc(hidden)]
1680    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1681    /// Test seam after the security read gate is held and before WAL append.
1682    #[doc(hidden)]
1683    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1684    /// Test seam after transaction preparation and before catalog generation
1685    /// validation under the commit sequencer.
1686    #[doc(hidden)]
1687    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1688    /// Test seam after a backup boundary is captured and before pinned runs are
1689    /// copied. Lets tests compact+GC the source at the worst possible moment.
1690    #[doc(hidden)]
1691    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1692    /// Test seam invoked after each successful FK parent-protection lock
1693    /// acquisition inside constraint validation, so tests can rendezvous two
1694    /// committing transactions into a deterministic wait-for cycle. Behind an
1695    /// `Arc` so firing never holds the slot's mutex — concurrent commits (and
1696    /// a hook that itself parks) must not serialize on it.
1697    #[doc(hidden)]
1698    fk_lock_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
1699    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1700    trigger_recursive: AtomicBool,
1701    trigger_max_depth: AtomicU32,
1702    trigger_max_loop_iterations: AtomicU32,
1703    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1704    /// is reconstructed from the WAL by [`Database::change_events_since`].
1705    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1706    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1707    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1708    change_wake: tokio::sync::broadcast::Sender<()>,
1709    /// Final field so every storage resource drops before the exclusive lease.
1710    /// Behind a `Mutex<Option<_>>` so `shutdown()` can release the file lock
1711    /// (spec §10.1, S1A-004 step 7) while handles still reference the core.
1712    _lock: Mutex<Option<ExclusiveDatabaseLease>>,
1713}
1714
1715/// The exclusive public owner of one [`DatabaseCore`] (spec §10.1, S1A-001).
1716///
1717/// `Database::open`/`create*` build exactly one core for a root and reject any
1718/// other core — exclusive or shared — for the same root (spec §2.6). All
1719/// storage state lives on the core; this type adds the owner-bound identity
1720/// context (the cached principal and the shared auth state used by the
1721/// embedded enforcement path). Method calls and field reads transparently
1722/// reach the core through `Deref`, so existing `Database` call sites are
1723/// unchanged.
1724pub struct Database {
1725    core: Arc<DatabaseCore>,
1726    /// The authenticated principal for this owner. `None` on databases
1727    /// opened without credentials (the default — `require_auth = false`),
1728    /// `Some` on credentialed opens. Consulted by every enforcement point
1729    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1730    /// because the access pattern is read-heavy: every `require()` call
1731    /// reads the principal, while writes happen only at open, `enable_auth`,
1732    /// and `refresh_principal`. This matches the engine's existing use of
1733    /// `RwLock` for `catalog` and `tables`.
1734    /// See `docs/15-credential-enforcement.md`.
1735    principal: RwLock<Option<crate::auth::Principal>>,
1736    /// Shared, cloneable handle to the auth state (require_auth flag from the
1737    /// catalog + the principal). Cloned into every mounted `Table` so the
1738    /// Table layer can enforce permissions without holding a reference back
1739    /// to `Database` (which would create a cycle). `AuthState` is already
1740    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1741    auth_state: crate::auth_state::AuthState,
1742    /// `true` when this value is a manager-issued facade over a shared core
1743    /// (Stage 1A): dropping it has no storage side effects, `shutdown()`
1744    /// rejects, and auth-mode transitions are refused so one handle cannot
1745    /// flip the shared core into an enforcement mode other handles cannot
1746    /// observe (fail closed; per-handle enforcement lands with Stage 1D
1747    /// sessions).
1748    shared: bool,
1749}
1750
1751impl std::ops::Deref for Database {
1752    type Target = DatabaseCore;
1753
1754    fn deref(&self) -> &DatabaseCore {
1755        &self.core
1756    }
1757}
1758
1759struct RunPins {
1760    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1761    runs: Vec<(u64, u128)>,
1762}
1763
1764/// RAII proof that one transaction's lock holds release when the scope that
1765/// acquired them exits (S1B-004 step 12). Used by the commit path (covers
1766/// success, validation failure, and poison alike) and by DDL entry points
1767/// holding the Exclusive schema barrier.
1768struct TxnLockGuard<'a> {
1769    locks: &'a crate::locks::LockManager,
1770    txn_id: u64,
1771}
1772
1773impl Drop for TxnLockGuard<'_> {
1774    fn drop(&mut self) {
1775        self.locks.release_all(self.txn_id);
1776    }
1777}
1778
1779struct BackupFilePins {
1780    root: PathBuf,
1781}
1782
1783struct PendingTableDir {
1784    path: PathBuf,
1785    armed: bool,
1786}
1787
1788impl PendingTableDir {
1789    fn new(path: PathBuf) -> Self {
1790        Self { path, armed: true }
1791    }
1792
1793    fn disarm(&mut self) {
1794        self.armed = false;
1795    }
1796}
1797
1798impl Drop for PendingTableDir {
1799    fn drop(&mut self) {
1800        if self.armed {
1801            let _ = std::fs::remove_dir_all(&self.path);
1802        }
1803    }
1804}
1805
1806impl Drop for BackupFilePins {
1807    fn drop(&mut self) {
1808        let _ = std::fs::remove_dir_all(&self.root);
1809    }
1810}
1811
1812impl Drop for RunPins {
1813    fn drop(&mut self) {
1814        let mut pins = self.pins.lock();
1815        for run in &self.runs {
1816            if let Some(count) = pins.get_mut(run) {
1817                *count -= 1;
1818                if *count == 0 {
1819                    pins.remove(run);
1820                }
1821            }
1822        }
1823    }
1824}
1825
1826/// A durable data-change event reconstructed from committed WAL records, or an
1827/// ephemeral SQL `NOTIFY` event when `id` is `None`.
1828#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1829pub struct ChangeEvent {
1830    pub id: Option<String>,
1831    pub channel: String,
1832    pub table_id: Option<u64>,
1833    pub table: String,
1834    pub op: String,
1835    pub epoch: u64,
1836    pub txn_id: Option<u64>,
1837    pub message: Option<String>,
1838    pub data: Option<serde_json::Value>,
1839}
1840
1841#[derive(Debug, Clone)]
1842pub struct CdcBatch {
1843    pub events: Vec<ChangeEvent>,
1844    pub current_epoch: u64,
1845    pub earliest_epoch: Option<u64>,
1846    pub gap: bool,
1847}
1848
1849/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
1850/// (root, epoch, table count, encryption/auth state) without requiring every
1851/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
1852/// The raw field types carry locks, trait objects, and channels that have no
1853/// useful `Debug` output, so a hand-written impl is clearer than peppering
1854/// `#[allow(dead_code)]` skip attributes across two dozen fields.
1855impl std::fmt::Debug for Database {
1856    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1857        let cat = self.catalog.read();
1858        let principal_guard = self.principal.read();
1859        let principal: &str = principal_guard
1860            .as_ref()
1861            .map(|p| p.username.as_str())
1862            .unwrap_or("<none>");
1863        f.debug_struct("Database")
1864            .field("root", &self.root)
1865            .field("db_epoch", &cat.db_epoch)
1866            .field("open_generation", &"sidecar")
1867            .field("tables", &cat.tables.len())
1868            .field("visible_epoch", &self.epoch.visible().0)
1869            .field("encrypted", &self.kek.is_some())
1870            .field("require_auth", &cat.require_auth)
1871            .field("principal", &principal)
1872            .finish()
1873    }
1874}
1875
1876/// Manual `Debug` for `DatabaseCore`, mirroring `Database`'s (see above). The
1877/// core has no cached principal by design (spec §4.6).
1878impl std::fmt::Debug for DatabaseCore {
1879    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1880        let cat = self.catalog.read();
1881        f.debug_struct("DatabaseCore")
1882            .field("root", &self.root)
1883            .field("lifecycle", &self.lifecycle.state())
1884            .field("db_epoch", &cat.db_epoch)
1885            .field("tables", &cat.tables.len())
1886            .field("visible_epoch", &self.epoch.visible().0)
1887            .field("encrypted", &self.kek.is_some())
1888            .field("require_auth", &cat.require_auth)
1889            .finish()
1890    }
1891}
1892
1893impl DatabaseCore {
1894    fn ensure_owner_process(&self) -> Result<()> {
1895        let current_pid = std::process::id();
1896        let owner_pid = self
1897            ._lock
1898            .lock()
1899            .as_ref()
1900            .map(|lease| lease.owner_pid)
1901            .unwrap_or(current_pid);
1902        if current_pid == owner_pid {
1903            Ok(())
1904        } else {
1905            Err(MongrelError::ForkedProcess {
1906                owner_pid,
1907                current_pid,
1908            })
1909        }
1910    }
1911
1912    /// The canonical filesystem root this core was opened at.
1913    pub fn root(&self) -> &Path {
1914        self.durable_root.canonical_path()
1915    }
1916
1917    /// The stable directory-handle identity of this core's root (S1A-003).
1918    pub fn file_identity(&self) -> Result<crate::core::DatabaseFileIdentity> {
1919        crate::core::DatabaseFileIdentity::from_durable_root(&self.durable_root)
1920    }
1921
1922    /// The current lifecycle state (S1A-004).
1923    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
1924        self.lifecycle.state()
1925    }
1926
1927    /// Whether this core currently admits new operations.
1928    pub fn is_open(&self) -> bool {
1929        self.lifecycle.is_open()
1930    }
1931
1932    /// Admit one operation against this core (S1A-004: "every operation holds
1933    /// an `OperationGuard`"). Rejected unless the core is
1934    /// [`crate::core::LifecycleState::Open`].
1935    pub fn operation_guard(self: &Arc<Self>) -> Result<crate::core::OperationGuard> {
1936        self.lifecycle.begin_operation()
1937    }
1938
1939    /// Register this core with the [`crate::manager::DatabaseManager`] that
1940    /// initialized it (S1A-002). Called once, before the first handle is
1941    /// handed out.
1942    pub(crate) fn set_registry(
1943        &self,
1944        registration: crate::manager::CoreRegistration,
1945    ) -> Result<()> {
1946        self.registry
1947            .set(registration)
1948            .map_err(|_| MongrelError::Conflict("database core is already registry-bound".into()))
1949    }
1950
1951    /// Ordered core shutdown (spec §10.1, S1A-004):
1952    ///
1953    /// 1. Transition `Open` → `Draining`.
1954    /// 2. Reject new sessions and writes (new [`Self::operation_guard`]s fail
1955    ///    from this point).
1956    /// 3. Cancel non-commit-critical queries — a no-op in Stage 1A: the
1957    ///    embedded core has no central query registry yet (Stage 1D adds one),
1958    ///    and every in-flight operation is treated as commit-critical and
1959    ///    drained rather than cancelled.
1960    /// 4. Wait for in-flight operations, up to `drain_deadline`. On timeout
1961    ///    the core stays `Draining` and a later call may resume the shutdown.
1962    /// 5. Sync required durable state (group-sync the shared WAL).
1963    /// 6. Stop workers — the Stage 1A core runs no background worker set.
1964    /// 7. Release the file lock (and the process open-registry entries).
1965    /// 8. Mark `Closed`.
1966    ///
1967    /// Repeated calls after `Closed` return `Ok(())`. Dropping one handle has
1968    /// no storage side effects; only this method (or the last `Arc` drop)
1969    /// closes storage.
1970    pub fn shutdown(self: &Arc<Self>, drain_deadline: std::time::Duration) -> Result<()> {
1971        self.ensure_owner_process()?;
1972        let initiated = self.lifecycle.begin_shutdown();
1973        if !initiated {
1974            match self.lifecycle.state() {
1975                crate::core::LifecycleState::Closed => return Ok(()),
1976                // A concurrent or timed-out shutdown left the core draining;
1977                // join it and drive the remaining steps.
1978                crate::core::LifecycleState::Draining => {}
1979                state => {
1980                    return Err(MongrelError::Conflict(format!(
1981                        "database core cannot shut down from lifecycle state {state}"
1982                    )))
1983                }
1984            }
1985        }
1986        if let Some(registration) = self.registry.get() {
1987            registration
1988                .manager
1989                .mark_closing(&registration.identity, self);
1990        }
1991        self.lifecycle.wait_drained(drain_deadline)?;
1992        let sync = self.shared_wal.lock().group_sync().map(|_| ());
1993        self.lifecycle.mark_closing();
1994        let lease = self._lock.lock().take();
1995        drop(lease);
1996        if let Some(registration) = self.registry.get() {
1997            registration
1998                .manager
1999                .entry_closed(&registration.identity, self);
2000        }
2001        self.lifecycle.mark_closed();
2002        sync
2003    }
2004}
2005
2006impl Database {
2007    pub fn open_metrics() -> DatabaseOpenMetrics {
2008        DatabaseOpenMetrics {
2009            lock_waits: DATABASE_OPEN_WAIT_COUNT.load(Ordering::Relaxed),
2010            failures: DATABASE_OPEN_FAILURE_COUNT.load(Ordering::Relaxed),
2011        }
2012    }
2013
2014    /// The storage core this owner references (spec §10.1, S1A-001).
2015    pub fn core(&self) -> Arc<DatabaseCore> {
2016        Arc::clone(&self.core)
2017    }
2018
2019    /// The identity bound to this owner (spec §10.1, S1A-001): a catalog user
2020    /// for credentialed opens, `Credentialless` otherwise.
2021    pub fn identity(&self) -> crate::handle::HandleIdentity {
2022        match self.principal.read().as_ref() {
2023            Some(principal) => crate::handle::HandleIdentity::CatalogUser {
2024                username: principal.username.clone(),
2025                user_id: principal.user_id,
2026                created_version: principal.created_epoch,
2027            },
2028            None => crate::handle::HandleIdentity::Credentialless,
2029        }
2030    }
2031
2032    /// The current lifecycle state of the underlying storage core (S1A-004).
2033    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2034        self.core.lifecycle_state()
2035    }
2036
2037    /// Admit one operation against the storage core (S1A-004). The returned
2038    /// RAII guard releases the operation slot on drop; new operations are
2039    /// rejected once the core leaves [`crate::core::LifecycleState::Open`].
2040    pub fn operation_guard(&self) -> Result<crate::core::OperationGuard> {
2041        self.core.operation_guard()
2042    }
2043
2044    /// Build an owner facade over an existing shared core. Used by
2045    /// [`crate::manager::DatabaseManager`] to back [`crate::handle::DatabaseHandle`]s;
2046    /// exclusive `Database::open*` constructors build their own core instead.
2047    pub(crate) fn from_core(
2048        core: Arc<DatabaseCore>,
2049        principal: Option<crate::auth::Principal>,
2050        shared: bool,
2051    ) -> Self {
2052        let require_auth = core.catalog.read().require_auth;
2053        Self {
2054            core,
2055            principal: RwLock::new(principal.clone()),
2056            auth_state: crate::auth_state::AuthState::new(require_auth, principal),
2057            shared,
2058        }
2059    }
2060
2061    /// Consume this owner and yield the shared core (keeps the core alive).
2062    pub(crate) fn into_core(self) -> Arc<DatabaseCore> {
2063        self.core
2064    }
2065
2066    /// Open an existing plaintext database as the one core backing shared
2067    /// handles (spec §10.1, S1A-002). Recovery, WAL opening, open-generation
2068    /// advancement, and table mounting all happen inside this call — exactly
2069    /// once per core.
2070    pub(crate) fn open_for_shared_core(root: impl AsRef<Path>) -> Result<Self> {
2071        Self::open_inner_with_lock_timeout(
2072            root,
2073            None,
2074            None,
2075            0,
2076            CoreResourceConfig::default(),
2077            OpenModeGate::Normal,
2078        )
2079    }
2080
2081    /// Explicitly close the final shared database owner.
2082    ///
2083    /// On a manager-issued facade (`shared`) this rejects: dropping one handle
2084    /// never closes storage while another handle exists, and shared cores are
2085    /// shut down explicitly via [`crate::handle::DatabaseHandle::shutdown`].
2086    pub fn shutdown(self: Arc<Self>) -> Result<()> {
2087        if self.shared {
2088            return Err(MongrelError::Conflict(
2089                "shared-core facades do not own storage; use DatabaseHandle::shutdown".into(),
2090            ));
2091        }
2092        match Arc::try_unwrap(self) {
2093            Ok(database) => {
2094                database.ensure_owner_process()?;
2095                // Drain + close the exclusively-owned core (S1A-004 steps:
2096                // reject new operations, wait for the drain, sync durable
2097                // state, release the file lock, mark Closed), then drop it.
2098                database.core.shutdown(std::time::Duration::from_secs(30))?;
2099                drop(database);
2100                Ok(())
2101            }
2102            Err(database) => Err(MongrelError::DatabaseBusy {
2103                strong_handles: Arc::strong_count(&database),
2104            }),
2105        }
2106    }
2107
2108    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
2109        if let Ok(canonical) = root.canonicalize() {
2110            let lock_dir = canonical.parent().ok_or_else(|| {
2111                std::io::Error::new(
2112                    std::io::ErrorKind::InvalidInput,
2113                    "database root must have a parent directory",
2114                )
2115            })?;
2116            return Ok((canonical.clone(), lock_dir.to_path_buf()));
2117        }
2118
2119        let absolute = if root.is_absolute() {
2120            root.to_path_buf()
2121        } else {
2122            std::env::current_dir()?.join(root)
2123        };
2124        let mut cursor = absolute.as_path();
2125        let mut suffix = Vec::new();
2126        while !cursor.exists() {
2127            let name = cursor.file_name().ok_or_else(|| {
2128                std::io::Error::new(
2129                    std::io::ErrorKind::NotFound,
2130                    format!("no existing ancestor for database root {}", root.display()),
2131                )
2132            })?;
2133            suffix.push(name.to_os_string());
2134            cursor = cursor.parent().ok_or_else(|| {
2135                std::io::Error::new(
2136                    std::io::ErrorKind::NotFound,
2137                    format!("no existing ancestor for database root {}", root.display()),
2138                )
2139            })?;
2140        }
2141        let lock_dir = cursor.canonicalize()?;
2142        let mut canonical = lock_dir.clone();
2143        for component in suffix.iter().rev() {
2144            canonical.push(component);
2145        }
2146        Ok((canonical, lock_dir))
2147    }
2148
2149    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<ExclusiveDatabaseLease> {
2150        use std::hash::{Hash, Hasher};
2151
2152        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
2153        let reservation =
2154            OpenReservation::acquire(DatabaseOpenKey::IntendedPath(canonical_path.clone()), root)?;
2155
2156        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2157        canonical_path.hash(&mut hasher);
2158        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
2159        let file = std::fs::OpenOptions::new()
2160            .create(true)
2161            .truncate(false)
2162            .write(true)
2163            .open(lock_path)?;
2164        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2165            return Err(MongrelError::DatabaseLocked {
2166                path: root.to_path_buf(),
2167                message: error.to_string(),
2168            });
2169        }
2170        Ok(reservation.into_lease(file, canonical_path))
2171    }
2172
2173    fn acquire_legacy_database_lock(
2174        lock: &mut ExclusiveDatabaseLease,
2175        root: &Path,
2176        timeout_ms: u32,
2177    ) -> Result<()> {
2178        let durable_root = lock
2179            .durable_root
2180            .as_ref()
2181            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
2182        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
2183        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2184            return Err(MongrelError::DatabaseLocked {
2185                path: root.to_path_buf(),
2186                message: error.to_string(),
2187            });
2188        }
2189        lock.legacy_file = Some(file);
2190        Ok(())
2191    }
2192
2193    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
2194        if path.exists() {
2195            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
2196        }
2197        let mut ancestor = path;
2198        while !ancestor.exists() {
2199            ancestor = ancestor.parent().ok_or_else(|| {
2200                MongrelError::NotFound(format!(
2201                    "no existing ancestor for database root {}",
2202                    path.display()
2203                ))
2204            })?;
2205        }
2206        let relative = path.strip_prefix(ancestor).map_err(|error| {
2207            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
2208        })?;
2209        crate::durable_file::DurableRoot::open(ancestor)?
2210            .create_directory_all_pinned(relative)
2211            .map_err(Into::into)
2212    }
2213
2214    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2215        let requested_root = root.as_ref();
2216        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
2217        let root = lock.canonical_path.clone();
2218        Self::reject_existing_database(&root)?;
2219        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
2220        if durable_root.canonical_path() != lock.canonical_path {
2221            return Err(MongrelError::Conflict(
2222                "database root changed while it was being created".into(),
2223            ));
2224        }
2225        lock.claim_root_identity(&durable_root)?;
2226        durable_root.create_directory_all(META_DIR)?;
2227        lock.durable_root = Some(durable_root);
2228        let io_root = lock
2229            .durable_root
2230            .as_ref()
2231            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2232            .io_path()?;
2233        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
2234        Self::reject_existing_database(&io_root)?;
2235        Ok((io_root, lock))
2236    }
2237
2238    fn begin_open(
2239        root: impl AsRef<Path>,
2240        lock_timeout_ms: u32,
2241    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2242        let root = root.as_ref();
2243        let canonical_root = root.canonicalize().map_err(|error| {
2244            if error.kind() == std::io::ErrorKind::NotFound {
2245                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
2246            } else {
2247                error.into()
2248            }
2249        })?;
2250        let durable_root = crate::durable_file::DurableRoot::open(&canonical_root)?;
2251        Self::begin_open_durable(durable_root, lock_timeout_ms)
2252    }
2253
2254    fn begin_open_durable(
2255        durable_root: crate::durable_file::DurableRoot,
2256        lock_timeout_ms: u32,
2257    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2258        let io_root = durable_root.io_path()?;
2259        let current_root = io_root.canonicalize()?;
2260        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
2261        lock.claim_root_identity(&durable_root)?;
2262        lock.durable_root = Some(Arc::new(durable_root));
2263        let io_root = lock
2264            .durable_root
2265            .as_ref()
2266            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2267            .io_path()?;
2268        if lock
2269            .durable_root
2270            .as_ref()
2271            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2272            .open_directory(META_DIR)
2273            .is_err()
2274        {
2275            return Err(MongrelError::NotFound(format!(
2276                "no database metadata found at {:?}",
2277                current_root
2278            )));
2279        }
2280        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
2281        Ok((io_root, lock))
2282    }
2283
2284    /// Create a fresh plaintext database at `root`.
2285    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
2286        let (root, lock) = Self::begin_create(root)?;
2287        Self::create_inner(
2288            root,
2289            None,
2290            lock,
2291            crate::storage_mode::StorageMode::Standalone,
2292        )
2293    }
2294
2295    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
2296    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
2297    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2298        let (root, lock) = Self::begin_create(root)?;
2299        let salt = crate::encryption::random_salt()?;
2300        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2301        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2302        Self::create_inner(
2303            root,
2304            Some(kek),
2305            lock,
2306            crate::storage_mode::StorageMode::Standalone,
2307        )
2308    }
2309
2310    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
2311    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
2312    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2313        let (root, lock) = Self::begin_create(root)?;
2314        let salt = crate::encryption::random_salt()?;
2315        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2316        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2317        Self::create_inner(
2318            root,
2319            Some(kek),
2320            lock,
2321            crate::storage_mode::StorageMode::Standalone,
2322        )
2323    }
2324
2325    /// Create a fresh plaintext database owned by the cluster node runtime
2326    /// (spec section 5.3): the durable marker records
2327    /// [`crate::storage_mode::StorageMode::ClusterReplica`], so normal
2328    /// `Database::open*` paths reject the directory and only
2329    /// [`Database::open_cluster_replica`] (or the read-only offline validator)
2330    /// can open it afterwards. The returned core rejects user writes: every
2331    /// mutation arrives through the replicated apply path (Stage 2E).
2332    pub fn create_cluster_replica(
2333        root: impl AsRef<Path>,
2334        cluster_id: mongreldb_types::ids::ClusterId,
2335        node_id: mongreldb_types::ids::NodeId,
2336        database_id: mongreldb_types::ids::DatabaseId,
2337    ) -> Result<Self> {
2338        let (root, lock) = Self::begin_create(root)?;
2339        Self::create_inner(
2340            root,
2341            None,
2342            lock,
2343            crate::storage_mode::StorageMode::ClusterReplica {
2344                cluster_id,
2345                node_id,
2346                database_id,
2347            },
2348        )
2349    }
2350
2351    /// Open a cluster-replica database as the cluster node runtime (spec
2352    /// section 5.3). Fails closed unless the durable marker exists and exactly
2353    /// matches `expected` (a `ClusterReplica` identity): opening a replica of
2354    /// the wrong cluster or database would corrupt both. The opened core
2355    /// rejects user writes with [`MongrelError::ReadOnlyReplica`]; mutations
2356    /// arrive through the replicated apply path (Stage 2E).
2357    pub fn open_cluster_replica(
2358        root: impl AsRef<Path>,
2359        expected: &crate::storage_mode::StorageMode,
2360    ) -> Result<Self> {
2361        let Some((cluster_id, node_id, database_id)) = expected.cluster_identity() else {
2362            return Err(MongrelError::InvalidArgument(format!(
2363                "open_cluster_replica requires a ClusterReplica identity, got {expected:?}"
2364            )));
2365        };
2366        let (root, lock) = Self::begin_open(root, 0)?;
2367        Self::open_inner_locked(
2368            root,
2369            None,
2370            lock,
2371            CoreResourceConfig::default(),
2372            OpenModeGate::ClusterRuntime {
2373                cluster_id,
2374                node_id,
2375                database_id,
2376            },
2377        )
2378    }
2379
2380    fn create_inner(
2381        root: PathBuf,
2382        kek: Option<Arc<crate::encryption::Kek>>,
2383        lock: ExclusiveDatabaseLease,
2384        storage_mode: crate::storage_mode::StorageMode,
2385    ) -> Result<Self> {
2386        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2387        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2388        let cat = Catalog::empty();
2389        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2390        Self::finish_open(
2391            root,
2392            cat,
2393            kek,
2394            meta_dek,
2395            false,
2396            None,
2397            None,
2398            None,
2399            lock,
2400            CoreResourceConfig::default(),
2401            OpenModeGate::Create(storage_mode),
2402        )
2403    }
2404
2405    /// Open an existing plaintext database.
2406    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
2407        Self::open_inner(root, None, None)
2408    }
2409
2410    /// Open an existing encrypted database with a passphrase.
2411    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2412        let (root, lock) = Self::begin_open(root, 0)?;
2413        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2414            MongrelError::Other("database root descriptor was not pinned".into())
2415        })?)?;
2416        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2417        Self::open_inner_locked(
2418            root,
2419            Some(kek),
2420            lock,
2421            CoreResourceConfig::default(),
2422            OpenModeGate::Normal,
2423        )
2424    }
2425
2426    /// Open an existing encrypted database with a configurable cross-process
2427    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
2428    pub fn open_encrypted_with_options(
2429        root: impl AsRef<Path>,
2430        passphrase: &str,
2431        options: OpenOptions,
2432    ) -> Result<Self> {
2433        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2434        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2435            MongrelError::Other("database root descriptor was not pinned".into())
2436        })?)?;
2437        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2438        let gate = if options.offline_validation {
2439            OpenModeGate::OfflineValidation
2440        } else {
2441            OpenModeGate::Normal
2442        };
2443        Self::open_inner_locked(
2444            root,
2445            Some(kek),
2446            lock,
2447            CoreResourceConfig::from_options(&options)?,
2448            gate,
2449        )
2450    }
2451
2452    /// Open an existing encrypted database using a raw high-entropy key.
2453    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2454        let (root, lock) = Self::begin_open(root, 0)?;
2455        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2456            MongrelError::Other("database root descriptor was not pinned".into())
2457        })?)?;
2458        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2459        Self::open_inner_locked(
2460            root,
2461            Some(kek),
2462            lock,
2463            CoreResourceConfig::default(),
2464            OpenModeGate::Normal,
2465        )
2466    }
2467
2468    /// Open an existing plaintext database that has `require_auth = true`,
2469    /// verifying the supplied credentials up front and caching the resolved
2470    /// [`Principal`] on the returned handle. Every subsequent operation will
2471    /// be checked against that principal.
2472    ///
2473    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
2474    /// `require_auth` enabled — callers must pick the matching constructor for
2475    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
2476    /// bad username/password.
2477    ///
2478    /// See `docs/15-credential-enforcement.md`.
2479    pub fn open_with_credentials(
2480        root: impl AsRef<Path>,
2481        username: &str,
2482        password: &str,
2483    ) -> Result<Self> {
2484        Self::open_inner_with_credentials(root, None, username, password)
2485    }
2486
2487    /// Open with credentials and a configurable cross-process lock timeout.
2488    /// Mirrors [`open_with_options`](Self::open_with_options) for the
2489    /// credentialed path.
2490    pub fn open_with_credentials_and_options(
2491        root: impl AsRef<Path>,
2492        username: &str,
2493        password: &str,
2494        options: OpenOptions,
2495    ) -> Result<Self> {
2496        let gate = if options.offline_validation {
2497            OpenModeGate::OfflineValidation
2498        } else {
2499            OpenModeGate::Normal
2500        };
2501        Self::open_inner_with_credentials_and_lock_timeout(
2502            root,
2503            None,
2504            username,
2505            password,
2506            options.lock_timeout_ms,
2507            CoreResourceConfig::from_options(&options)?,
2508            gate,
2509        )
2510    }
2511
2512    /// Open an existing encrypted database that has `require_auth = true`,
2513    /// combining the encryption passphrase flow with credential verification.
2514    pub fn open_encrypted_with_credentials(
2515        root: impl AsRef<Path>,
2516        passphrase: &str,
2517        username: &str,
2518        password: &str,
2519    ) -> Result<Self> {
2520        let (root, lock) = Self::begin_open(root, 0)?;
2521        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2522            MongrelError::Other("database root descriptor was not pinned".into())
2523        })?)?;
2524        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2525        Self::open_inner_with_credentials_locked(
2526            root,
2527            Some(kek),
2528            username,
2529            password,
2530            lock,
2531            CoreResourceConfig::default(),
2532            OpenModeGate::Normal,
2533        )
2534    }
2535
2536    /// Open an encrypted + credentialed database with a configurable
2537    /// cross-process lock timeout. Mirrors
2538    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
2539    pub fn open_encrypted_with_credentials_and_options(
2540        root: impl AsRef<Path>,
2541        passphrase: &str,
2542        username: &str,
2543        password: &str,
2544        options: OpenOptions,
2545    ) -> Result<Self> {
2546        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2547        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2548            MongrelError::Other("database root descriptor was not pinned".into())
2549        })?)?;
2550        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2551        let gate = if options.offline_validation {
2552            OpenModeGate::OfflineValidation
2553        } else {
2554            OpenModeGate::Normal
2555        };
2556        Self::open_inner_with_credentials_locked(
2557            root,
2558            Some(kek),
2559            username,
2560            password,
2561            lock,
2562            CoreResourceConfig::from_options(&options)?,
2563            gate,
2564        )
2565    }
2566
2567    /// Open an existing database with non-default [`OpenOptions`].
2568    ///
2569    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
2570    /// rather than the fail-fast default. The other open constructors keep
2571    /// their previous defaults; use their `*_with_options` variants when they
2572    /// need the same timeout behavior.
2573    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
2574        // No encryption, no auth; encrypted and credentialed paths have their
2575        // own `*_with_options` constructors.
2576        let gate = if options.offline_validation {
2577            OpenModeGate::OfflineValidation
2578        } else {
2579            OpenModeGate::Normal
2580        };
2581        Self::open_inner_with_lock_timeout(
2582            root,
2583            None,
2584            None,
2585            options.lock_timeout_ms,
2586            CoreResourceConfig::from_options(&options)?,
2587            gate,
2588        )
2589    }
2590
2591    fn open_inner_with_lock_timeout(
2592        root: impl AsRef<Path>,
2593        kek: Option<Arc<crate::encryption::Kek>>,
2594        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2595        lock_timeout_ms: u32,
2596        resources: CoreResourceConfig,
2597        mode_gate: OpenModeGate,
2598    ) -> Result<Self> {
2599        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
2600        Self::open_inner_locked(root, kek, lock, resources, mode_gate)
2601    }
2602
2603    fn open_inner_locked(
2604        root: PathBuf,
2605        kek: Option<Arc<crate::encryption::Kek>>,
2606        lock: ExclusiveDatabaseLease,
2607        resources: CoreResourceConfig,
2608        mode_gate: OpenModeGate,
2609    ) -> Result<Self> {
2610        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2611        let mut cat = catalog::read_durable(
2612            lock.durable_root.as_deref().ok_or_else(|| {
2613                MongrelError::Other("database root descriptor was not pinned".into())
2614            })?,
2615            meta_dek.as_ref(),
2616        )?
2617        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2618        let recovery_checkpoint = cat.clone();
2619
2620        // CATALOG is only a checkpoint. Authentication must use the
2621        // authoritative catalog after committed WAL DDL/security replay.
2622        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2623        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2624            lock.durable_root.as_deref().ok_or_else(|| {
2625                MongrelError::Other("database root descriptor was not pinned".into())
2626            })?,
2627            wal_dek.as_ref(),
2628        )?;
2629        recover_ddl_from_records(
2630            &root,
2631            Some(lock.durable_root.as_deref().ok_or_else(|| {
2632                MongrelError::Other("database root descriptor was not pinned".into())
2633            })?),
2634            &mut cat,
2635            meta_dek.as_ref(),
2636            false,
2637            None,
2638            &recovery_records,
2639        )?;
2640        Self::finish_open(
2641            root,
2642            cat,
2643            kek,
2644            meta_dek,
2645            true,
2646            Some(recovery_checkpoint),
2647            Some(recovery_records),
2648            None,
2649            lock,
2650            resources,
2651            mode_gate,
2652        )
2653    }
2654
2655    /// Shared credentialed-open inner: read the catalog, verify the database
2656    /// requires auth, verify the password, resolve the principal, and pass
2657    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
2658    /// problem where `finish_open`'s fail-closed check (`require_auth &&
2659    /// principal.is_none()`) would fire before a post-open `authenticate()`
2660    /// could supply the principal.
2661    fn open_inner_with_credentials(
2662        root: impl AsRef<Path>,
2663        kek: Option<Arc<crate::encryption::Kek>>,
2664        username: &str,
2665        password: &str,
2666    ) -> Result<Self> {
2667        Self::open_inner_with_credentials_and_lock_timeout(
2668            root,
2669            kek,
2670            username,
2671            password,
2672            0,
2673            CoreResourceConfig::default(),
2674            OpenModeGate::Normal,
2675        )
2676    }
2677
2678    /// Credentialed-open with an explicit cross-process lock timeout. The
2679    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
2680    /// historical fail-fast behavior via the wrapper above.
2681    #[allow(clippy::too_many_arguments)]
2682    fn open_inner_with_credentials_and_lock_timeout(
2683        root: impl AsRef<Path>,
2684        kek: Option<Arc<crate::encryption::Kek>>,
2685        username: &str,
2686        password: &str,
2687        lock_timeout_ms: u32,
2688        resources: CoreResourceConfig,
2689        mode_gate: OpenModeGate,
2690    ) -> Result<Self> {
2691        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
2692        Self::open_inner_with_credentials_locked(
2693            root, kek, username, password, lock, resources, mode_gate,
2694        )
2695    }
2696
2697    #[allow(clippy::too_many_arguments)]
2698    fn open_inner_with_credentials_locked(
2699        root: PathBuf,
2700        kek: Option<Arc<crate::encryption::Kek>>,
2701        username: &str,
2702        password: &str,
2703        lock: ExclusiveDatabaseLease,
2704        resources: CoreResourceConfig,
2705        mode_gate: OpenModeGate,
2706    ) -> Result<Self> {
2707        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2708        let mut cat = catalog::read_durable(
2709            lock.durable_root.as_deref().ok_or_else(|| {
2710                MongrelError::Other("database root descriptor was not pinned".into())
2711            })?,
2712            meta_dek.as_ref(),
2713        )?
2714        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2715        let recovery_checkpoint = cat.clone();
2716
2717        // Never verify against a stale checkpoint. A committed password,
2718        // user, role, or auth-mode change in WAL is authoritative.
2719        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2720        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2721            lock.durable_root.as_deref().ok_or_else(|| {
2722                MongrelError::Other("database root descriptor was not pinned".into())
2723            })?,
2724            wal_dek.as_ref(),
2725        )?;
2726        recover_ddl_from_records(
2727            &root,
2728            Some(lock.durable_root.as_deref().ok_or_else(|| {
2729                MongrelError::Other("database root descriptor was not pinned".into())
2730            })?),
2731            &mut cat,
2732            meta_dek.as_ref(),
2733            false,
2734            None,
2735            &recovery_records,
2736        )?;
2737
2738        // Fail early if the database is not in require_auth mode — the caller
2739        // picked the wrong constructor.
2740        if !cat.require_auth {
2741            return Err(MongrelError::AuthNotRequired);
2742        }
2743
2744        // Verify credentials against the on-disk catalog before constructing
2745        // the full Database handle. This reads users/hashes directly from the
2746        // loaded catalog rather than going through the Database::verify_user
2747        // method (which requires a constructed Database).
2748        let user = cat
2749            .users
2750            .iter()
2751            .find(|u| u.username == username)
2752            .filter(|u| !u.password_hash.is_empty())
2753            .ok_or_else(|| MongrelError::InvalidCredentials {
2754                username: username.to_string(),
2755            })?;
2756        let password_ok = crate::auth::verify_password(password, &user.password_hash)
2757            .map_err(MongrelError::Other)?;
2758        if !password_ok {
2759            return Err(MongrelError::InvalidCredentials {
2760                username: username.to_string(),
2761            });
2762        }
2763
2764        // Resolve the principal from the catalog (roles + permissions).
2765        let principal =
2766            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
2767                MongrelError::InvalidCredentials {
2768                    username: username.to_string(),
2769                }
2770            })?;
2771
2772        Self::finish_open(
2773            root,
2774            cat,
2775            kek,
2776            meta_dek,
2777            true,
2778            Some(recovery_checkpoint),
2779            Some(recovery_records),
2780            Some(principal),
2781            lock,
2782            resources,
2783            mode_gate,
2784        )
2785    }
2786
2787    /// Create a fresh plaintext database with `require_auth = true` and a
2788    /// single admin user. The returned handle is already authenticated as
2789    /// that admin — every subsequent operation is checked against the admin
2790    /// principal (which bypasses all permission checks via `is_admin`).
2791    ///
2792    /// This is the bootstrap path: there is no window where the database
2793    /// requires auth but has no users.
2794    ///
2795    /// See `docs/15-credential-enforcement.md`.
2796    pub fn create_with_credentials(
2797        root: impl AsRef<Path>,
2798        admin_username: &str,
2799        admin_password: &str,
2800    ) -> Result<Self> {
2801        let (root, lock) = Self::begin_create(root)?;
2802        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
2803    }
2804
2805    /// Create a fresh encrypted database with `require_auth = true` and a
2806    /// single admin user. Composes encryption-at-rest with credential
2807    /// enforcement.
2808    pub fn create_encrypted_with_credentials(
2809        root: impl AsRef<Path>,
2810        passphrase: &str,
2811        admin_username: &str,
2812        admin_password: &str,
2813    ) -> Result<Self> {
2814        let (root, lock) = Self::begin_create(root)?;
2815        let salt = crate::encryption::random_salt()?;
2816        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2817        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2818        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
2819    }
2820
2821    fn create_inner_with_credentials(
2822        root: PathBuf,
2823        kek: Option<Arc<crate::encryption::Kek>>,
2824        admin_username: &str,
2825        admin_password: &str,
2826        lock: ExclusiveDatabaseLease,
2827    ) -> Result<Self> {
2828        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2829        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2830
2831        // Build the initial catalog with require_auth = true and one admin user.
2832        let password_hash =
2833            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2834        let mut cat = Catalog::empty();
2835        cat.require_auth = true;
2836        cat.next_user_id = 2;
2837        cat.users.push(crate::auth::UserEntry {
2838            id: 1,
2839            username: admin_username.to_string(),
2840            password_hash,
2841            roles: Vec::new(),
2842            is_admin: true,
2843            created_epoch: 0,
2844        });
2845        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2846
2847        // The handle is constructed already authenticated as the admin user
2848        // it just created — no separate verify step needed.
2849        let admin_principal = crate::auth::Principal {
2850            user_id: 1,
2851            created_epoch: 0,
2852            username: admin_username.to_string(),
2853            is_admin: true,
2854            roles: Vec::new(),
2855            permissions: Vec::new(),
2856        };
2857        Self::finish_open(
2858            root,
2859            cat,
2860            kek,
2861            meta_dek,
2862            false,
2863            None,
2864            None,
2865            Some(admin_principal),
2866            lock,
2867            CoreResourceConfig::default(),
2868            OpenModeGate::Create(crate::storage_mode::StorageMode::Standalone),
2869        )
2870    }
2871
2872    fn reject_existing_database(root: &Path) -> Result<()> {
2873        // Refuse to overwrite an existing database. If CATALOG exists, the
2874        // directory already contains a real database; replacing it destroys data.
2875        if root.join(catalog::CATALOG_FILENAME).exists() {
2876            return Err(MongrelError::InvalidArgument(format!(
2877                "database already exists at {}; use Database::open() to open it, \
2878                 or remove the directory first",
2879                root.display()
2880            )));
2881        }
2882        Ok(())
2883    }
2884
2885    fn open_inner(
2886        root: impl AsRef<Path>,
2887        kek: Option<Arc<crate::encryption::Kek>>,
2888        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2889    ) -> Result<Self> {
2890        Self::open_inner_with_lock_timeout(
2891            root,
2892            kek,
2893            None,
2894            0,
2895            CoreResourceConfig::default(),
2896            OpenModeGate::Normal,
2897        )
2898    }
2899
2900    /// Open an existing plaintext database through the special
2901    /// offline-validation API (spec section 5.3): any storage mode, read-only.
2902    pub(crate) fn open_offline_validation(root: impl AsRef<Path>) -> Result<Self> {
2903        Self::open_inner_with_lock_timeout(
2904            root,
2905            None,
2906            None,
2907            0,
2908            CoreResourceConfig::default(),
2909            OpenModeGate::OfflineValidation,
2910        )
2911    }
2912
2913    /// Internal recovery open for a staging directory explicitly marked as a
2914    /// read-only replica. It bypasses user authentication only so PITR can
2915    /// replay auth-mode and password transitions; it is not public API.
2916    pub(crate) fn open_replica_recovery_durable(
2917        root: &crate::durable_file::DurableRoot,
2918    ) -> Result<Self> {
2919        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2920        Self::open_replica_recovery_inner(root, None, lock)
2921    }
2922
2923    pub(crate) fn open_encrypted_replica_recovery_durable(
2924        root: &crate::durable_file::DurableRoot,
2925        passphrase: &str,
2926    ) -> Result<Self> {
2927        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2928        let salt = read_encryption_salt(root)?;
2929        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2930        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
2931    }
2932
2933    fn open_replica_recovery_inner(
2934        root: PathBuf,
2935        kek: Option<Arc<crate::encryption::Kek>>,
2936        lock: ExclusiveDatabaseLease,
2937    ) -> Result<Self> {
2938        if !root.join(META_DIR).join("replica").is_file() {
2939            return Err(MongrelError::InvalidArgument(
2940                "recovery auth bypass requires a marked replica staging directory".into(),
2941            ));
2942        }
2943        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2944        let mut cat = catalog::read_durable(
2945            lock.durable_root.as_deref().ok_or_else(|| {
2946                MongrelError::Other("database root descriptor was not pinned".into())
2947            })?,
2948            meta_dek.as_ref(),
2949        )?
2950        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2951        let recovery_checkpoint = cat.clone();
2952        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2953        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2954            lock.durable_root.as_deref().ok_or_else(|| {
2955                MongrelError::Other("database root descriptor was not pinned".into())
2956            })?,
2957            wal_dek.as_ref(),
2958        )?;
2959        recover_ddl_from_records(
2960            &root,
2961            Some(lock.durable_root.as_deref().ok_or_else(|| {
2962                MongrelError::Other("database root descriptor was not pinned".into())
2963            })?),
2964            &mut cat,
2965            meta_dek.as_ref(),
2966            false,
2967            None,
2968            &recovery_records,
2969        )?;
2970        let principal = if cat.require_auth {
2971            cat.users
2972                .iter()
2973                .find(|user| user.is_admin)
2974                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
2975                .ok_or_else(|| {
2976                    MongrelError::Schema(
2977                        "authenticated replica catalog has no recoverable admin".into(),
2978                    )
2979                })?
2980                .into()
2981        } else {
2982            None
2983        };
2984        Self::finish_open(
2985            root,
2986            cat,
2987            kek,
2988            meta_dek,
2989            true,
2990            Some(recovery_checkpoint),
2991            Some(recovery_records),
2992            principal,
2993            lock,
2994            CoreResourceConfig::default(),
2995            OpenModeGate::Normal,
2996        )
2997    }
2998
2999    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
3000    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
3001    ///
3002    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
3003    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
3004    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
3005    ///
3006    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
3007    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
3008    /// caller never blocks past its budget even at the tail of a busy lock
3009    /// holder's lock-window.
3010    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
3011        use fs2::FileExt;
3012        if timeout_ms == 0 {
3013            return f.try_lock_exclusive();
3014        }
3015        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
3016        let deadline =
3017            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
3018        let mut next_sleep = std::time::Duration::from_millis(1);
3019        let mut recorded_wait = false;
3020        loop {
3021            match f.try_lock_exclusive() {
3022                Ok(()) => return Ok(()),
3023                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
3024                    if !recorded_wait {
3025                        DATABASE_OPEN_WAIT_COUNT.fetch_add(1, Ordering::Relaxed);
3026                        recorded_wait = true;
3027                    }
3028                    let now = std::time::Instant::now();
3029                    if now >= deadline {
3030                        return Err(std::io::Error::new(
3031                            std::io::ErrorKind::WouldBlock,
3032                            format!("could not acquire database lock within {timeout_ms}ms"),
3033                        ));
3034                    }
3035                    let remaining = deadline - now;
3036                    let sleep = next_sleep.min(remaining);
3037                    std::thread::sleep(sleep);
3038                    // Cap the per-iteration sleep so a single back-off step
3039                    // never overshoots the remaining budget.
3040                    next_sleep = next_sleep
3041                        .saturating_mul(10)
3042                        .min(std::time::Duration::from_millis(50));
3043                }
3044                Err(e) => return Err(e),
3045            }
3046        }
3047    }
3048
3049    #[allow(clippy::too_many_arguments)]
3050    fn finish_open(
3051        root: PathBuf,
3052        cat: Catalog,
3053        kek: Option<Arc<crate::encryption::Kek>>,
3054        meta_dek: Option<[u8; META_DEK_LEN]>,
3055        existing: bool,
3056        recovery_checkpoint: Option<Catalog>,
3057        recovery_records: Option<Vec<crate::wal::Record>>,
3058        principal: Option<crate::auth::Principal>,
3059        lock: ExclusiveDatabaseLease,
3060        resources: CoreResourceConfig,
3061        mode_gate: OpenModeGate,
3062    ) -> Result<Self> {
3063        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
3064            MongrelError::Other("database root descriptor was not pinned".into())
3065        })?);
3066        // Storage-mode gate (spec section 5.3, Stage 2E): read the durable
3067        // marker before any recovery side effect and fail closed on corrupt
3068        // or forbidden modes. Legacy databases have no marker and are treated
3069        // as Standalone (backfilled below, after recovery succeeds).
3070        let storage_mode = if existing {
3071            crate::storage_mode::read(&durable_root)?
3072        } else {
3073            None
3074        };
3075        match (&mode_gate, &storage_mode) {
3076            (OpenModeGate::Create(_), _) => {}
3077            (OpenModeGate::Normal, mode) => crate::storage_mode::check_open(mode.as_ref(), false)?,
3078            (OpenModeGate::OfflineValidation, mode) => {
3079                crate::storage_mode::check_open(mode.as_ref(), true)?
3080            }
3081            (
3082                OpenModeGate::ClusterRuntime {
3083                    cluster_id,
3084                    node_id,
3085                    database_id,
3086                },
3087                Some(actual),
3088            ) => {
3089                let expected = crate::storage_mode::StorageMode::ClusterReplica {
3090                    cluster_id: *cluster_id,
3091                    node_id: *node_id,
3092                    database_id: *database_id,
3093                };
3094                if *actual != expected {
3095                    return Err(
3096                        crate::storage_mode::StorageModeError::IdentityMismatch(format!(
3097                            "expected {expected:?}, marker holds {actual:?}"
3098                        ))
3099                        .into(),
3100                    );
3101                }
3102            }
3103            (OpenModeGate::ClusterRuntime { .. }, None) => {
3104                return Err(crate::storage_mode::StorageModeError::IdentityMismatch(
3105                    "expected a ClusterReplica marker; directory has none".into(),
3106                )
3107                .into());
3108            }
3109        }
3110        let read_only = match &mode_gate {
3111            OpenModeGate::OfflineValidation | OpenModeGate::ClusterRuntime { .. } => true,
3112            // A freshly created cluster replica rejects user writes from the
3113            // start: mutations arrive through the replicated apply path only.
3114            OpenModeGate::Create(mode) => mode.cluster_identity().is_some(),
3115            OpenModeGate::Normal => false,
3116        } || if existing {
3117            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
3118                Ok(_) => true,
3119                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
3120                Err(error) => return Err(error.into()),
3121            }
3122        } else {
3123            false
3124        };
3125        let recovered_catalog = cat;
3126        let mut cat = recovered_catalog.clone();
3127        let abandoned = if existing && !read_only {
3128            let abandoned = cat
3129                .tables
3130                .iter()
3131                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
3132                .map(|entry| entry.table_id)
3133                .collect::<Vec<_>>();
3134            for entry in &mut cat.tables {
3135                if abandoned.contains(&entry.table_id) {
3136                    entry.state = TableState::Dropped {
3137                        at_epoch: cat.db_epoch,
3138                    };
3139                }
3140            }
3141            abandoned
3142        } else {
3143            Vec::new()
3144        };
3145        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3146        let recovery_records = match (existing, recovery_records) {
3147            (true, Some(records)) => records,
3148            (true, None) => {
3149                return Err(MongrelError::Other(
3150                    "existing open has no validated WAL recovery plan".into(),
3151                ))
3152            }
3153            (false, _) => Vec::new(),
3154        };
3155        let (history_epochs, history_start) =
3156            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
3157        let open_generation = if existing {
3158            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
3159                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3160            })?;
3161            let recovered_table_ids = cat
3162                .tables
3163                .iter()
3164                .filter(|entry| {
3165                    checkpoint
3166                        .tables
3167                        .iter()
3168                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
3169                })
3170                .map(|entry| entry.table_id)
3171                .collect::<HashSet<_>>();
3172            let reconciled_table_ids = cat
3173                .tables
3174                .iter()
3175                .filter(|entry| {
3176                    checkpoint
3177                        .tables
3178                        .iter()
3179                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
3180                        .is_some_and(|checkpoint| {
3181                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
3182                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
3183                        })
3184                })
3185                .map(|entry| entry.table_id)
3186                .collect::<HashSet<_>>();
3187            validate_shared_wal_recovery_plan(
3188                &durable_root,
3189                &cat,
3190                &recovered_table_ids,
3191                &reconciled_table_ids,
3192                meta_dek.as_ref(),
3193                kek.clone(),
3194                &recovery_records,
3195            )?;
3196            let retained_generation = recovery_records
3197                .iter()
3198                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
3199                .map(|record| record.txn_id >> 32)
3200                .max()
3201                .unwrap_or(0);
3202            let head_generation =
3203                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
3204            let durable_floor = match head_generation {
3205                Some(head) if retained_generation > head => {
3206                    return Err(MongrelError::CorruptWal {
3207                        offset: retained_generation,
3208                        reason: format!(
3209                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
3210                        ),
3211                    })
3212                }
3213                Some(head) => head,
3214                None => retained_generation,
3215            };
3216            let stored = catalog::read_generation(&durable_root)?;
3217            if stored.is_some_and(|generation| generation < durable_floor) {
3218                return Err(MongrelError::Other(format!(
3219                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
3220                )));
3221            }
3222            let bumped = stored
3223                .unwrap_or(durable_floor)
3224                .max(durable_floor)
3225                .checked_add(1)
3226                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
3227            if bumped > u32::MAX as u64 {
3228                return Err(MongrelError::Full(
3229                    "open-generation namespace exhausted".into(),
3230                ));
3231            }
3232            bumped
3233        } else {
3234            0
3235        };
3236        let principal = if cat.require_auth {
3237            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3238            Some(
3239                Self::resolve_bound_principal_from_catalog(&cat, supplied)
3240                    .ok_or(MongrelError::AuthRequired)?,
3241            )
3242        } else {
3243            principal
3244        };
3245        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
3246        if existing {
3247            for entry in &cat.tables {
3248                if !matches!(entry.state, TableState::Live) {
3249                    continue;
3250                }
3251                match durable_root
3252                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
3253                {
3254                    Ok(root) => {
3255                        table_roots.insert(entry.table_id, Arc::new(root));
3256                    }
3257                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3258                    Err(error) => return Err(error.into()),
3259                }
3260            }
3261        }
3262
3263        // No database-tree mutation occurs above this point. DDL, row payloads,
3264        // immutable runs, auth state, retention, and generation state have all
3265        // been validated against the authoritative recovered catalog.
3266        if existing {
3267            let mut applied = recovery_checkpoint.ok_or_else(|| {
3268                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3269            })?;
3270            recover_ddl_from_records(
3271                &root,
3272                Some(&durable_root),
3273                &mut applied,
3274                meta_dek.as_ref(),
3275                true,
3276                Some(&table_roots),
3277                &recovery_records,
3278            )?;
3279            let catalog_value = |catalog: &Catalog| {
3280                serde_json::to_value(catalog)
3281                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
3282            };
3283            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
3284                return Err(MongrelError::CorruptWal {
3285                    offset: 0,
3286                    reason: "validated and applied DDL recovery plans differ".into(),
3287                });
3288            }
3289            if catalog_value(&cat)? != catalog_value(&applied)? {
3290                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3291            }
3292            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
3293            if !read_only {
3294                sweep_unreferenced_table_dirs(&root, &cat)?;
3295            }
3296            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
3297                Ok(()) => {}
3298                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3299                Err(error) => return Err(error.into()),
3300            }
3301        }
3302
3303        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
3304        let snapshots = Arc::new(SnapshotRegistry::new());
3305        snapshots.configure_history(history_epochs, history_start);
3306        // S1E-003: exactly one node-level memory governor per storage core.
3307        // Both caches reserve under it (their live bytes always show up in
3308        // the governor's accounting) and register as reclaimable subsystems
3309        // the governor drives under escalation step 2.
3310        let memory_governor = crate::memory::MemoryGovernor::new(
3311            crate::memory::GovernorConfig::new(resources.memory_budget_bytes),
3312        )
3313        .map_err(|error| MongrelError::InvalidArgument(format!("memory governor: {error}")))?;
3314        // S1E-004: exactly one spill manager per core, rooted at
3315        // `<db-root>/temp/spill`; opening it runs the startup sweep that
3316        // deletes every stale spill file a prior process run left behind.
3317        let spill_manager = crate::spill::SpillManager::open(
3318            &durable_root,
3319            crate::spill::SpillConfig::new(resources.temp_disk_budget_bytes),
3320            meta_dek,
3321        )?;
3322        // S1F-002: exactly one durable job registry per core (sibling `JOBS`
3323        // file). Crash recovery inside `open` parks mid-`Running` jobs as
3324        // `Paused` for an operator-driven resume.
3325        let job_registry = Arc::new(crate::jobs::JobRegistry::open(&root, meta_dek.as_ref())?);
3326        let page_cache = Arc::new(crate::cache::Sharded::new(
3327            crate::cache::CACHE_SHARDS,
3328            || {
3329                crate::cache::PageCache::new(
3330                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3331                )
3332                .with_governor(
3333                    memory_governor.clone(),
3334                    crate::memory::MemoryClass::PageCache,
3335                )
3336            },
3337        ));
3338        memory_governor.register_reclaimable(&page_cache);
3339        let decoded_cache = Arc::new(crate::cache::Sharded::new(
3340            crate::cache::CACHE_SHARDS,
3341            || {
3342                crate::cache::DecodedPageCache::new(
3343                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3344                )
3345                .with_governor(
3346                    memory_governor.clone(),
3347                    crate::memory::MemoryClass::DecodedCache,
3348                )
3349            },
3350        ));
3351        memory_governor.register_reclaimable(&decoded_cache);
3352        let commit_lock = Arc::new(Mutex::new(()));
3353        let shared_wal = Arc::new(Mutex::new(if existing {
3354            crate::wal::SharedWal::open_durable_root_validated(
3355                Arc::clone(&durable_root),
3356                Epoch(cat.db_epoch),
3357                wal_dek.clone(),
3358                Some(&recovery_records),
3359            )?
3360        } else {
3361            crate::wal::SharedWal::create_with_durable_root(
3362                Arc::clone(&durable_root),
3363                Epoch(cat.db_epoch),
3364                wal_dek.clone(),
3365            )?
3366        }));
3367        // Shared write-path state handed to every mounted table so single-table
3368        // `put`/`commit` writes route through the one shared WAL, the one group-
3369        // commit coordinator, and the one poison flag (B1).
3370        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
3371        // S1A-004: the lifecycle is created before the write-path plumbing so
3372        // the group-commit coordinator and every mounted table can poison it on
3373        // an unrecoverable fsync error; `mark_open` happens only once every
3374        // initialization step below has completed.
3375        let lifecycle = Arc::new(crate::core::LifecycleController::new());
3376        let group = Arc::new(
3377            crate::txn::GroupCommit::new(shared_wal.lock().durable_seq())
3378                .with_lifecycle(Arc::clone(&lifecycle)),
3379        );
3380        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
3381        // Final base value is set after the open-generation bump below; tables
3382        // only draw ids once the user issues a write (post-open), so the
3383        // placeholder is never observed.
3384        let txn_ids = Arc::new(Mutex::new(1u64));
3385        let _ = abandoned;
3386        // FND-004 (spec §9.4): the commit-log authority for this database. The
3387        // transaction-id allocator Arc is stable; its base value is re-seeded
3388        // below once the open generation is final. The HLC clock is the node's
3389        // single timestamp authority (spec §4.1, §8.2; ADR-0003): standalone
3390        // mode uses node tiebreaker 0, and the skew bound only engages once
3391        // remote timestamps are observed (Stage 2 replication).
3392        let hlc = Arc::new(mongreldb_types::hlc::HlcClock::new(
3393            0,
3394            std::time::Duration::from_millis(500),
3395        ));
3396        // S1B-005: durable idempotency ledger (sibling `TXN_IDEMPOTENCY` file
3397        // via `DurableRoot::write_atomic`, mirroring `jobs.rs`'s `JOBS`).
3398        let idempotency = crate::txn::IdempotencyLedger::open(Arc::clone(&durable_root), meta_dek)?;
3399        let commit_log = Arc::new(crate::commit_log::StandaloneCommitLog::new(
3400            Arc::clone(&shared_wal),
3401            Arc::clone(&group),
3402            Arc::clone(&epoch),
3403            Arc::clone(&commit_lock),
3404            Arc::clone(&txn_ids),
3405            root.clone(),
3406            wal_dek.clone(),
3407            Arc::clone(&hlc),
3408        ));
3409
3410        // Build the shared auth state early — it's cloned into every mounted
3411        // Table's SharedCtx so the Table layer can enforce permissions without
3412        // a reference back to Database. The `require_auth` flag is mirrored
3413        // from the catalog; `enable_auth` / `refresh_principal` update it live.
3414        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
3415        let security_coordinator = security_coordinator(&root, cat.security_version);
3416        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
3417            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
3418        ));
3419
3420        // Open every live table against the shared context. Mounted tables have
3421        // no private WAL (B1) — `open_in` just loads the manifest/runs and
3422        // advances the shared epoch authority to its manifest epoch, so the
3423        // final shared watermark is the max across all tables. All of a mounted
3424        // table's committed records are replayed below from the shared WAL.
3425        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
3426        for entry in &cat.tables {
3427            if !matches!(entry.state, TableState::Live) {
3428                continue;
3429            }
3430            let table_root = match table_roots.remove(&entry.table_id) {
3431                Some(root) => root,
3432                None => Arc::new(
3433                    durable_root
3434                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
3435                ),
3436            };
3437            let tdir = table_root.io_path()?;
3438            let ctx = SharedCtx {
3439                root_guard: Some(table_root),
3440                epoch: Arc::clone(&epoch),
3441                page_cache: Arc::clone(&page_cache),
3442                decoded_cache: Arc::clone(&decoded_cache),
3443                snapshots: Arc::clone(&snapshots),
3444                kek: kek.clone(),
3445                commit_lock: Arc::clone(&commit_lock),
3446                shared: Some(crate::engine::SharedWalCtx {
3447                    wal: Arc::clone(&shared_wal),
3448                    group: Arc::clone(&group),
3449                    poisoned: Arc::clone(&poisoned),
3450                    txn_ids: Arc::clone(&txn_ids),
3451                    change_wake: change_wake.clone(),
3452                    lifecycle: Arc::clone(&lifecycle),
3453                }),
3454                table_name: Some(entry.name.clone()),
3455                auth: auth_checker.clone(),
3456                read_only,
3457            };
3458            let t = Table::open_in(&tdir, ctx)?;
3459            tables.insert(entry.table_id, TableHandle::new(t));
3460        }
3461
3462        // Recover transaction writes from the shared WAL (spec §15). This is the
3463        // single durability source for mounted tables: it applies every committed
3464        // record — both single-table `Table::commit` writes and cross-table
3465        // transactions — gated by each table's `flushed_epoch` (records already
3466        // durable in a run are not re-applied).
3467        if existing {
3468            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
3469            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
3470            if read_only {
3471                crate::replication::reconcile_replica_epoch_durable(
3472                    &durable_root,
3473                    epoch.visible().0,
3474                )?;
3475            }
3476            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
3477            // large transactions (spec §8.5, review fix #14).
3478            sweep_pending_txn_dirs(&root, &cat);
3479        }
3480
3481        // Persist only after all semantic recovery and table mounting succeeds.
3482        catalog::write_generation(&durable_root, open_generation)?;
3483        // Storage-mode marker (spec section 5.3): fresh creates record their
3484        // mode; legacy databases (no marker file) are backfilled as Standalone
3485        // on first open. Like the open-generation write above, this is durable
3486        // open bookkeeping, so it also runs for read-only opens.
3487        match &mode_gate {
3488            OpenModeGate::Create(mode) => crate::storage_mode::write(&durable_root, mode)?,
3489            _ if existing && storage_mode.is_none() => {
3490                crate::storage_mode::write(
3491                    &durable_root,
3492                    &crate::storage_mode::StorageMode::Standalone,
3493                )?;
3494            }
3495            _ => {}
3496        }
3497        shared_wal.lock().seal_open_generation(open_generation)?;
3498        crate::replication::replication_identity_durable(&durable_root)?;
3499        let next_txn_id = (open_generation << 32) | 1;
3500        // Seed the shared txn-id allocator now that the generation is final.
3501        *txn_ids.lock() = next_txn_id;
3502        let mut lock = lock;
3503        lock.mark_open()?;
3504
3505        // Initialization is complete: recovery, WAL opening, open-generation
3506        // advancement, and table mounting all happened above, exactly once for
3507        // this core (spec §10.1, S1A-002). The core starts life `Open`.
3508        lifecycle.mark_open();
3509        let core = DatabaseCore {
3510            root,
3511            durable_root,
3512            lifecycle,
3513            registry: std::sync::OnceLock::new(),
3514            read_only,
3515            catalog: RwLock::new(cat),
3516            security_coordinator,
3517            security_catalog_disk_reads: AtomicU64::new(0),
3518            rls_cache: Mutex::new(RlsCache::default()),
3519            epoch,
3520            snapshots,
3521            memory_governor,
3522            page_cache,
3523            decoded_cache,
3524            spill_manager,
3525            resource_groups: crate::resource::ResourceGroupRegistry::with_defaults(),
3526            embedding_providers: crate::embedding::EmbeddingProviderRegistry::new(),
3527            job_registry,
3528            commit_lock,
3529            shared_wal,
3530            next_txn_id: txn_ids,
3531            tables: RwLock::new(tables),
3532            kek,
3533            ddl_lock: Mutex::new(()),
3534            meta_dek,
3535            conflicts: crate::txn::ConflictIndex::new(),
3536            active_txns: crate::txn::ActiveTxns::new(),
3537            lock_manager: Arc::new(crate::locks::LockManager::new()),
3538            poisoned,
3539            group,
3540            commit_log,
3541            hlc,
3542            idempotency,
3543            commit_ts_ledger: Mutex::new(commit_ts_ledger_from_recovery(&recovery_records)),
3544            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
3545            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
3546            replication_barrier: parking_lot::RwLock::new(()),
3547            replication_wal_retention_segments: AtomicUsize::new(0),
3548            backup_pins: Arc::new(Mutex::new(HashMap::new())),
3549            spill_hook: Mutex::new(None),
3550            security_commit_hook: Mutex::new(None),
3551            catalog_commit_hook: Mutex::new(None),
3552            backup_hook: Mutex::new(None),
3553            fk_lock_hook: Mutex::new(None),
3554            replication_hook: Mutex::new(None),
3555            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
3556            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
3557            trigger_max_loop_iterations: AtomicU32::new(
3558                TriggerConfig::default().max_loop_iterations,
3559            ),
3560            notify: {
3561                let (tx, _rx) = tokio::sync::broadcast::channel(256);
3562                tx
3563            },
3564            change_wake,
3565            _lock: Mutex::new(Some(lock)),
3566        };
3567        Ok(Self {
3568            core: Arc::new(core),
3569            principal: RwLock::new(principal),
3570            auth_state,
3571            shared: false,
3572        })
3573    }
3574
3575    /// The current reader-visible epoch.
3576    pub fn visible_epoch(&self) -> Epoch {
3577        self.epoch.visible()
3578    }
3579
3580    /// The catalog's monotonic command version (S1F-001).
3581    pub fn catalog_version(&self) -> u64 {
3582        self.catalog.read().catalog_version
3583    }
3584
3585    /// The durable storage mode recorded for this database (spec section 5.3).
3586    /// `None` only while a legacy pre-marker database is mid-open (the marker
3587    /// is backfilled before open completes).
3588    pub fn storage_mode(&self) -> Result<Option<crate::storage_mode::StorageMode>> {
3589        Ok(crate::storage_mode::read(&self.durable_root)?)
3590    }
3591
3592    /// The core's node-level memory governor (S1E-003). Exactly one per core;
3593    /// the page caches reserve under it and register as reclaimable.
3594    pub fn memory_governor(&self) -> &crate::memory::MemoryGovernor {
3595        &self.memory_governor
3596    }
3597
3598    /// Workload resource-group registry (S1E-002). Seeded with defaults for
3599    /// every [`crate::resource::WorkloadClass`] at open.
3600    pub fn resource_groups(&self) -> &crate::resource::ResourceGroupRegistry {
3601        &self.resource_groups
3602    }
3603
3604    /// Process-local embedding provider registry. Core storage never hard-codes
3605    /// an external vendor; register local/remote providers here when generation
3606    /// is desired. Application-supplied vectors need no registration.
3607    pub fn embedding_providers(&self) -> &crate::embedding::EmbeddingProviderRegistry {
3608        &self.embedding_providers
3609    }
3610
3611    /// Acquire exclusive row locks for `SELECT ... FOR UPDATE` (spec §10.2).
3612    /// Locks are held until the transaction ends (`release_txn_locks` /
3613    /// commit / abort). Requires an open transaction id from the SQL session.
3614    pub fn lock_rows_for_update(
3615        &self,
3616        txn_id: u64,
3617        table_id: u64,
3618        row_ids: &[crate::rowid::RowId],
3619        control: Option<&crate::ExecutionControl>,
3620    ) -> Result<()> {
3621        for &row_id in row_ids {
3622            self.acquire_txn_lock(
3623                txn_id,
3624                crate::locks::LockKey::row(table_id, row_id),
3625                crate::locks::LockMode::Exclusive,
3626                control,
3627            )?;
3628        }
3629        Ok(())
3630    }
3631
3632    /// The core's node-level spill manager (S1E-004), rooted at
3633    /// `<db-root>/temp/spill`. Query engines open per-query spill sessions
3634    /// from it.
3635    pub fn spill_manager(&self) -> &crate::spill::SpillManager {
3636        &self.spill_manager
3637    }
3638
3639    /// The core's durable job registry (S1F-002, sibling `JOBS` file).
3640    pub fn job_registry(&self) -> &Arc<crate::jobs::JobRegistry> {
3641        &self.job_registry
3642    }
3643
3644    /// S1C-004 diagnostics: every mounted table's active version-retention
3645    /// pin sources, aggregated from [`crate::engine::Table::version_pins_report`].
3646    /// A version may be reclaimed only when it is older than the oldest pin of
3647    /// every source; this report exposes each of them per table.
3648    pub fn version_pins_report(&self) -> Vec<TablePinsReport> {
3649        let names: HashMap<u64, String> = self
3650            .catalog
3651            .read()
3652            .tables
3653            .iter()
3654            .map(|entry| (entry.table_id, entry.name.clone()))
3655            .collect();
3656        let handles: Vec<_> = self
3657            .tables
3658            .read()
3659            .iter()
3660            .map(|(table_id, handle)| (*table_id, handle.clone()))
3661            .collect();
3662        handles
3663            .into_iter()
3664            .map(|(table_id, handle)| {
3665                let pins = handle.lock().version_pins_report();
3666                TablePinsReport {
3667                    table_id,
3668                    table: names.get(&table_id).cloned().unwrap_or_default(),
3669                    pins,
3670                }
3671            })
3672            .collect()
3673    }
3674
3675    /// The core's key/predicate lock manager (S1B-003).
3676    pub fn lock_manager(&self) -> &Arc<crate::locks::LockManager> {
3677        &self.lock_manager
3678    }
3679
3680    /// S1B-004 step 12: release every lock `txn_id` holds (commit, abort, and
3681    /// rollback all funnel here). Idempotent — releasing a transaction with
3682    /// no holds is a no-op.
3683    /// Release every lock held by `txn_id` (COMMIT/ROLLBACK/FOR UPDATE end).
3684    pub fn release_txn_locks(&self, txn_id: u64) {
3685        self.lock_manager.release_all(txn_id);
3686    }
3687
3688    /// Build a lock request for `txn_id`, inheriting the commit's
3689    /// cancellation control (or a plain one when the commit is uncontrolled).
3690    fn txn_lock_request(
3691        txn_id: u64,
3692        mode: crate::locks::LockMode,
3693        control: Option<&crate::ExecutionControl>,
3694    ) -> crate::locks::LockRequest {
3695        let control = control
3696            .cloned()
3697            .unwrap_or_else(|| crate::ExecutionControl::new(None));
3698        crate::locks::LockRequest::new(txn_id, mode, control)
3699    }
3700
3701    /// Acquire one lock for `txn_id`, bridging the typed [`crate::locks::LockError`]
3702    /// onto the engine error (deadlock victims surface as
3703    /// [`MongrelError::Deadlock`] with [`mongreldb_types::error::ErrorCategory::Deadlock`]).
3704    pub(crate) fn acquire_txn_lock(
3705        &self,
3706        txn_id: u64,
3707        key: crate::locks::LockKey,
3708        mode: crate::locks::LockMode,
3709        control: Option<&crate::ExecutionControl>,
3710    ) -> Result<()> {
3711        self.lock_manager
3712            .acquire(key, Self::txn_lock_request(txn_id, mode, control))
3713            .map_err(MongrelError::from)
3714    }
3715
3716    /// S1B-003: acquire the schema barrier Exclusive for one DDL operation.
3717    /// The hold releases when the returned guard drops (end of the DDL entry
3718    /// point). DML transactions hold the barrier Shared for their whole
3719    /// commit, so schema changes exclude concurrent DML and one another.
3720    fn acquire_schema_barrier_exclusive(&self) -> Result<TxnLockGuard<'_>> {
3721        let txn_id = self.alloc_txn_id()?;
3722        self.acquire_txn_lock(
3723            txn_id,
3724            crate::locks::LockKey::schema_barrier(),
3725            crate::locks::LockMode::Exclusive,
3726            None,
3727        )?;
3728        Ok(TxnLockGuard {
3729            locks: &self.lock_manager,
3730            txn_id,
3731        })
3732    }
3733
3734    /// The commit-log authority for this database (spec §9.4, FND-004). Every
3735    /// commit path proposes through it and visibility publication is gated on
3736    /// its receipts; Stage 2 swaps this standalone adapter for the replicated
3737    /// implementation behind the same `CommitLog` trait.
3738    pub fn commit_log(&self) -> Arc<crate::commit_log::StandaloneCommitLog> {
3739        Arc::clone(&self.commit_log)
3740    }
3741
3742    /// The node's HLC timestamp authority (spec §8.2, ADR-0003). Transaction
3743    /// `begin` captures read timestamps here; the commit sequencer allocates
3744    /// commit timestamps from the same clock so commit ts > every participant
3745    /// read/write timestamp of the transaction.
3746    pub(crate) fn hlc_clock(&self) -> &mongreldb_types::hlc::HlcClock {
3747        &self.hlc
3748    }
3749
3750    /// Clone the in-memory catalog (for diagnostics / tests).
3751    pub fn catalog_snapshot(&self) -> Catalog {
3752        self.catalog.read().clone()
3753    }
3754
3755    /// Read SQLite-compatible application metadata persisted in the catalog.
3756    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
3757        let catalog = self.catalog.read();
3758        match key {
3759            "user_version" => Ok(catalog.user_version),
3760            "application_id" => Ok(catalog.application_id),
3761            _ => Err(MongrelError::InvalidArgument(format!(
3762                "unsupported persistent SQL pragma {key:?}"
3763            ))),
3764        }
3765    }
3766
3767    /// Persist SQLite-compatible application metadata and return its exact
3768    /// publication epoch. An unchanged value performs no durable write.
3769    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
3770        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
3771    }
3772
3773    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
3774        &self,
3775        key: &str,
3776        value: i64,
3777        mut before_commit: F,
3778    ) -> Result<Option<Epoch>>
3779    where
3780        F: FnMut() -> Result<()>,
3781    {
3782        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
3783    }
3784
3785    fn set_sql_pragma_i64_with_epoch_inner(
3786        &self,
3787        key: &str,
3788        value: i64,
3789        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
3790    ) -> Result<Option<Epoch>> {
3791        use crate::wal::DdlOp;
3792
3793        self.require(&crate::auth::Permission::Ddl)?;
3794        if self.read_only {
3795            return Err(MongrelError::ReadOnlyReplica);
3796        }
3797        if self.poisoned.load(Ordering::Relaxed) {
3798            return Err(MongrelError::Other(
3799                "database poisoned by fsync error".into(),
3800            ));
3801        }
3802        let _ddl = self.ddl_lock.lock();
3803        let _security_write = self.security_write()?;
3804        self.require(&crate::auth::Permission::Ddl)?;
3805        let mut next_catalog = self.catalog.read().clone();
3806        let target = match key {
3807            "user_version" => &mut next_catalog.user_version,
3808            "application_id" => &mut next_catalog.application_id,
3809            _ => {
3810                return Err(MongrelError::InvalidArgument(format!(
3811                    "unsupported persistent SQL pragma {key:?}"
3812                )))
3813            }
3814        };
3815        if *target == Some(value) {
3816            return Ok(None);
3817        }
3818        *target = Some(value);
3819
3820        let _commit = self.commit_lock.lock();
3821        let epoch = self.epoch.bump_assigned();
3822        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3823        let txn_id = self.alloc_txn_id()?;
3824        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
3825        let commit_seq = {
3826            let mut wal = self.shared_wal.lock();
3827            if let Some(before_commit) = before_commit {
3828                before_commit()?;
3829            }
3830            let append: Result<u64> = (|| {
3831                wal.append(
3832                    txn_id,
3833                    WAL_TABLE_ID,
3834                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
3835                        key: key.to_string(),
3836                        value,
3837                    }),
3838                )?;
3839                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
3840                wal.append_commit(txn_id, epoch, &[])
3841            })();
3842            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
3843        };
3844        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
3845        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
3846        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
3847        Ok(Some(epoch))
3848    }
3849
3850    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
3851        self.catalog
3852            .read()
3853            .materialized_views
3854            .iter()
3855            .find(|definition| definition.name == name)
3856            .cloned()
3857    }
3858
3859    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
3860        self.catalog.read().materialized_views.clone()
3861    }
3862
3863    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
3864        self.catalog.read().security.clone()
3865    }
3866
3867    pub fn security_active_for(&self, table: &str) -> bool {
3868        self.catalog.read().security.table_has_security(table)
3869    }
3870
3871    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
3872        if self.catalog.read().security_version == expected_version {
3873            return Ok(());
3874        }
3875        self.security_catalog_disk_reads
3876            .fetch_add(1, Ordering::Relaxed);
3877        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
3878            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
3879        let principal = self.principal.read().clone();
3880        let principal = if fresh.require_auth {
3881            principal
3882                .as_ref()
3883                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
3884        } else {
3885            principal
3886        };
3887        self.auth_state.set_require_auth(fresh.require_auth);
3888        *self.catalog.write() = fresh;
3889        *self.principal.write() = principal.clone();
3890        self.auth_state.set_principal(principal);
3891        Ok(())
3892    }
3893
3894    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
3895        let guard = self.security_coordinator.gate.write();
3896        let version = self.security_coordinator.version.load(Ordering::Acquire);
3897        self.refresh_security_catalog_if_stale(version)?;
3898        Ok(guard)
3899    }
3900
3901    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
3902    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
3903    /// only its restart checkpoint.
3904    /// S1A-004: admit one operation against the core (rejects once the core
3905    /// leaves [`crate::core::LifecycleState::Open`] — draining, closing,
3906    /// closed, or poisoned). The returned guard holds the operation slot
3907    /// until dropped, so `shutdown()` drains exactly the covered operations.
3908    ///
3909    /// Covered set (the mutating/durable paths; the legacy fsync-poison error
3910    /// still wins where a body already checks `self.poisoned`, because the
3911    /// guard is taken after that check):
3912    ///
3913    /// - the cross-table commit funnel
3914    ///   (`commit_transaction_with_external_states_inner`), covering every
3915    ///   user and internal transaction commit;
3916    /// - the catalog publish funnel (`publish_catalog_candidate_with_prelude`),
3917    ///   covering the user/role/grant, trigger, and procedure families;
3918    /// - the inline-WAL DDL bodies: table create/drop/rename/alter, CTAS
3919    ///   rebuilding publish, security-catalog and materialized-view
3920    ///   replacement, external-table create/drop/reset;
3921    /// - storage maintenance: `gc`, `checkpoint`, `compact`, `hot_backup`;
3922    /// - replication bootstrap, batch extraction, and follow-apply.
3923    ///
3924    /// Read-only entry points (snapshots, queries, stats) and the infallible
3925    /// `begin*` constructors are intentionally not covered: reads never block
3926    /// shutdown, and a transaction's durable act is its commit.
3927    pub(crate) fn admit_operation(&self) -> Result<crate::core::OperationGuard> {
3928        self.core.operation_guard()
3929    }
3930
3931    /// S1F-001: wrap `command` in its versioned record and apply it to the
3932    /// catalog candidate (validate → mutate → bump `catalog_version` → append
3933    /// the bounded command history). Security-catalog changes advance the
3934    /// security version inside `CatalogDelta::apply_to`, exactly where the
3935    /// legacy mutation bodies called `advance_security_version` themselves.
3936    fn apply_catalog_command_to(
3937        &self,
3938        next_catalog: &mut Catalog,
3939        command: crate::catalog_cmds::CatalogCommand,
3940    ) -> Result<crate::catalog_cmds::CatalogDelta> {
3941        let record = crate::catalog_cmds::CatalogCommandRecord::next(next_catalog, command);
3942        next_catalog.apply_command(&record)
3943    }
3944
3945    /// Stage 2E (spec section 11.5): apply one committed replicated
3946    /// transaction's staged records through the **same** logic the WAL
3947    /// recovery path uses ([`recover_shared_wal`]).
3948    ///
3949    /// The payload is a complete record sequence of one committed transaction
3950    /// (data ops, `Op::CommitTimestamp`, and exactly one trailing
3951    /// `Op::TxnCommit`), carrying the leader-assigned commit epoch so every
3952    /// replica applies byte-identical records deterministically.
3953    ///
3954    /// Application is durable before it returns: the records are appended
3955    /// verbatim to the core's shared WAL and group-synced, so the raft state
3956    /// machine's post-apply checkpoint never acknowledges rows a crash would
3957    /// lose. Application is idempotent: a payload whose commit epoch is at or
3958    /// below the core's visible watermark is a replay (the state machine
3959    /// dispatches sink-first, checkpoints second — a crash in that window
3960    /// redelivers) and is skipped without side effects. Returns `Ok(true)`
3961    /// when the payload was applied, `Ok(false)` for a recognized replay.
3962    pub fn apply_replicated_records(&self, records: &[crate::wal::Record]) -> Result<bool> {
3963        use crate::wal::Op;
3964        use std::sync::atomic::Ordering;
3965
3966        let _operation = self.admit_operation()?;
3967        if self.poisoned.load(Ordering::Relaxed) {
3968            return Err(MongrelError::Other(
3969                "database poisoned by fsync error".into(),
3970            ));
3971        }
3972        // Structural validation (fail closed): one transaction, exactly one
3973        // commit marker, at the tail.
3974        let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
3975            MongrelError::InvalidArgument("replicated transaction payload is empty".into())
3976        })?;
3977        if records.iter().any(|record| record.txn_id != txn_id) {
3978            return Err(MongrelError::InvalidArgument(
3979                "replicated transaction payload mixes transaction ids".into(),
3980            ));
3981        }
3982        let commits = records
3983            .iter()
3984            .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
3985            .count();
3986        if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
3987            return Err(MongrelError::InvalidArgument(
3988                "replicated transaction payload must end in exactly one commit marker".into(),
3989            ));
3990        }
3991        let commit_epoch = match records.last().map(|r| &r.op) {
3992            Some(Op::TxnCommit { epoch, .. }) => *epoch,
3993            _ => unreachable!("validated above"),
3994        };
3995        // Fail closed on spilled-run linking: an `added_runs` commit links
3996        // run files that exist only on the leader. Leaders never emit such a
3997        // payload — the Stage 2C envelope builder passes every commit through
3998        // [`translate_records_for_replication`], which stages the spilled rows
3999        // as logical `Op::Put` records and strips the run links (spec section
4000        // 11.3 step 3). This check is the defense-in-depth gate behind that
4001        // contract: a payload carrying run references is un-appliable here
4002        // and is rejected deterministically rather than diverging.
4003        if let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) {
4004            if !added_runs.is_empty() {
4005                return Err(MongrelError::InvalidArgument(
4006                    "replicated spilled-run commits are not appliable: the leader must translate \
4007                     the commit through translate_records_for_replication before proposal"
4008                        .into(),
4009                ));
4010            }
4011        }
4012        // Replay guard: the leader assigns one monotonically increasing epoch
4013        // per committed transaction in log order, so anything at or below the
4014        // core's watermark is already applied.
4015        if commit_epoch <= self.epoch.visible().0 {
4016            return Ok(false);
4017        }
4018        // Stage durable first: verbatim WAL append + fsync. The raft log is
4019        // the commit authority; the local WAL is this replica's durable
4020        // staging of already-committed commands (restart recovery replays it
4021        // through the identical path, gated by flushed epochs).
4022        {
4023            let mut wal = self.shared_wal.lock();
4024            for record in records {
4025                wal.append(record.txn_id, 0, record.op.clone())?;
4026            }
4027            if let Err(error) = wal.group_sync() {
4028                self.poisoned.store(true, Ordering::Relaxed);
4029                return Err(error);
4030            }
4031        }
4032        // S1C-004: pin the pre-apply visible epoch for the duration of apply so
4033        // concurrent GC cannot reclaim versions a catch-up reader still needs.
4034        let replication_pins: Vec<crate::retention::PinGuard> = {
4035            let tables = self.tables.read();
4036            let floor = Epoch(self.epoch.visible().0);
4037            tables
4038                .values()
4039                .map(|handle| {
4040                    let t = handle.lock();
4041                    Arc::clone(t.pin_registry())
4042                        .pin(crate::retention::PinSource::Replication, floor)
4043                })
4044                .collect()
4045        };
4046        let tables = self.tables.read().clone();
4047        let catalog = self.catalog.read().clone();
4048        recover_shared_wal(&self.durable_root, &tables, &catalog, &self.epoch, records)?;
4049        // Replica-side commit-ts ledger: record the leader-assigned commit
4050        // epoch's physical time from any CommitTimestamp op, else stamp from
4051        // the local HLC so `commit_ts_for_epoch` serves PITR/read-your-writes
4052        // on replicas (Stage 2 residual).
4053        if let Some(Op::TxnCommit { epoch, .. }) = records.last().map(|r| &r.op) {
4054            let commit_ts = records
4055                .iter()
4056                .rev()
4057                .find_map(|record| match &record.op {
4058                    Op::CommitTimestamp { unix_nanos } => {
4059                        Some(mongreldb_types::hlc::HlcTimestamp {
4060                            physical_micros: unix_nanos / 1_000,
4061                            logical: 0,
4062                            node_tiebreaker: 0,
4063                        })
4064                    }
4065                    _ => None,
4066                })
4067                .unwrap_or_else(|| {
4068                    self.hlc
4069                        .now()
4070                        .unwrap_or(mongreldb_types::hlc::HlcTimestamp {
4071                            physical_micros: 0,
4072                            logical: 0,
4073                            node_tiebreaker: 0,
4074                        })
4075                });
4076            self.record_commit_ts(Epoch(*epoch), commit_ts);
4077        }
4078        drop(replication_pins);
4079        Ok(true)
4080    }
4081
4082    /// Stage 3H engine binding (spec section 12.8): validate staged
4083    /// write-intent payloads at prepare time. A participant that durably
4084    /// prepares these payloads must be able to apply them at resolution, so
4085    /// every check the resolution apply performs runs here first: each
4086    /// payload decodes as a [`StagedTxnWrite`], its table is mounted, and
4087    /// every staged row satisfies the same persisted-row validation the WAL
4088    /// recovery path applies. A malformed payload is rejected deterministically
4089    /// at prepare — never after a decision commits, where a rejection would
4090    /// wedge the replicated apply stream.
4091    pub fn validate_staged_txn_writes(&self, staged: &[Vec<u8>]) -> Result<()> {
4092        let tables = self.tables.read();
4093        for payload in staged {
4094            match StagedTxnWrite::decode(payload)? {
4095                StagedTxnWrite::Put { table_id, rows } => {
4096                    let handle = tables.get(&table_id).ok_or_else(|| {
4097                        MongrelError::InvalidArgument(format!(
4098                            "staged write targets unmounted table {table_id}"
4099                        ))
4100                    })?;
4101                    let rows: Vec<crate::memtable::Row> =
4102                        bincode::deserialize(&rows).map_err(|error| {
4103                            MongrelError::InvalidArgument(format!(
4104                                "staged put payload for table {table_id} cannot decode: {error}"
4105                            ))
4106                        })?;
4107                    let schema = handle.lock().schema().clone();
4108                    for row in &rows {
4109                        validate_recovered_row(&schema, row).map_err(|error| {
4110                            MongrelError::InvalidArgument(format!(
4111                                "staged row for table {table_id} is not appliable: {error}"
4112                            ))
4113                        })?;
4114                    }
4115                }
4116                StagedTxnWrite::Delete { table_id, row_ids } => {
4117                    if !tables.contains_key(&table_id) {
4118                        return Err(MongrelError::InvalidArgument(format!(
4119                            "staged delete targets unmounted table {table_id}"
4120                        )));
4121                    }
4122                    if row_ids.contains(&u64::MAX) {
4123                        return Err(MongrelError::InvalidArgument(format!(
4124                            "staged delete for table {table_id} names an exhausted row id"
4125                        )));
4126                    }
4127                }
4128            }
4129        }
4130        Ok(())
4131    }
4132
4133    /// Stage 3H engine binding (spec section 12.8): apply one committed
4134    /// resolution's staged writes through the replicated apply path
4135    /// ([`Database::apply_replicated_records`]) at the decision's commit
4136    /// timestamp. `txn_tag` is the caller's deterministic per-transaction
4137    /// tag (e.g. a hash of the distributed transaction id); it must be
4138    /// identical on every replica. The synthetic WAL transaction id is
4139    /// derived from it inside the current open-generation namespace with the
4140    /// allocator-avoidance bit set, so resolution records pass the
4141    /// open-generation durability check on reopen and never alias
4142    /// leader-allocated transaction ids.
4143    ///
4144    /// The commit epoch stamped into the synthetic commit marker is one past
4145    /// the core's visible watermark: the replicated apply stream is
4146    /// deterministic, so every replica computes the identical epoch at the
4147    /// identical apply point, and the core's own restart recovery replays the
4148    /// stamped sequence verbatim. Rows are restamped at that epoch by the
4149    /// recovery logic, becoming visible exactly as an ordinary committed
4150    /// transaction's rows.
4151    pub fn apply_staged_txn_writes(
4152        &self,
4153        txn_tag: u64,
4154        staged: &[Vec<u8>],
4155        commit_ts: mongreldb_types::hlc::HlcTimestamp,
4156    ) -> Result<bool> {
4157        use crate::wal::Op;
4158
4159        // Decode every payload before any mutation (fail closed).
4160        let mut writes = Vec::with_capacity(staged.len());
4161        for payload in staged {
4162            writes.push(StagedTxnWrite::decode(payload)?);
4163        }
4164        // Generation-namespace the synthetic transaction id: the high 32 bits
4165        // are the current open generation (the reopen path rejects retained
4166        // records from a generation beyond the durable WAL head); the low 32
4167        // bits carry the caller's tag with the top bit set, outside the
4168        // ascending leader-allocated counter space.
4169        let generation = *self.next_txn_id.lock() >> 32;
4170        let txn_id = (generation << 32) | (txn_tag & 0x7FFF_FFFF) | 0x8000_0000;
4171        let mut records = Vec::with_capacity(writes.len() + 2);
4172        for write in writes {
4173            let op = match write {
4174                StagedTxnWrite::Put { table_id, rows } => Op::Put { table_id, rows },
4175                StagedTxnWrite::Delete { table_id, row_ids } => Op::Delete {
4176                    table_id,
4177                    row_ids: row_ids.into_iter().map(crate::RowId).collect(),
4178                },
4179            };
4180            records.push(crate::wal::Record::new(Epoch(0), txn_id, op));
4181        }
4182        let epoch = self.epoch.visible().0 + 1;
4183        // The physical component of the decision's commit timestamp goes into
4184        // the durable timestamp ledger, mirroring the ordinary commit path
4185        // (`commit_log::commit_nanos`).
4186        let unix_nanos = commit_ts.physical_micros.saturating_mul(1_000);
4187        records.push(crate::wal::Record::new(
4188            Epoch(0),
4189            txn_id,
4190            Op::CommitTimestamp { unix_nanos },
4191        ));
4192        records.push(crate::wal::Record::new(
4193            Epoch(0),
4194            txn_id,
4195            Op::TxnCommit {
4196                epoch,
4197                added_runs: Vec::new(),
4198            },
4199        ));
4200        self.apply_replicated_records(&records)
4201    }
4202
4203    /// Stage 2E (spec sections 10.6, 11.5): apply one committed replicated
4204    /// catalog command and checkpoint the catalog. The record travels as the
4205    /// payload of a replicated `Catalog` command envelope and routes through
4206    /// [`Catalog::apply_command`] — the S1F-001 versioned, idempotent command
4207    /// path (replaying an already-applied `catalog_version` is a no-op).
4208    /// Structural deltas are mirrored into the mounted table set: a created
4209    /// table is mounted, a dropped table unmounted. Deterministic: the record
4210    /// carries every resolved value (ids, epochs, complete images).
4211    pub fn apply_replicated_catalog_command(
4212        &self,
4213        record: &crate::catalog_cmds::CatalogCommandRecord,
4214    ) -> Result<crate::catalog_cmds::CatalogDelta> {
4215        let _operation = self.admit_operation()?;
4216        let _g = self.ddl_lock.lock();
4217        let mut next_catalog = self.catalog.read().clone();
4218        let delta = next_catalog.apply_command(record)?;
4219        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
4220            return Ok(delta);
4221        }
4222        // The leader references epochs in structural commands (a table's
4223        // creation/drop epoch). The replica's epoch stream must cover them:
4224        // epochs stay the commit sequencer's authority, so commands never
4225        // allocate one, but the watermark advances to every referenced epoch
4226        // (mirroring how `create_table_with_state` bumps `db_epoch`).
4227        let referenced_epoch = match &delta {
4228            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => Some(entry.created_epoch),
4229            crate::catalog_cmds::CatalogDelta::TableDropped { at_epoch, .. }
4230            | crate::catalog_cmds::CatalogDelta::TableRenamed { at_epoch, .. } => Some(*at_epoch),
4231            _ => None,
4232        };
4233        if let Some(referenced) = referenced_epoch {
4234            self.epoch.advance_recovered(Epoch(referenced));
4235            next_catalog.db_epoch = next_catalog.db_epoch.max(referenced);
4236        }
4237        match &delta {
4238            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => {
4239                // Guard against a repeated mount within one open (the state
4240                // machine's crash-window redispatch is filtered by the NoOp
4241                // arm above; this covers a catalog checkpoint that never
4242                // became durable before a crash).
4243                if !self.tables.read().contains_key(&entry.table_id) {
4244                    self.mount_catalog_entry(entry)?;
4245                }
4246            }
4247            crate::catalog_cmds::CatalogDelta::TableDropped { table_id, .. } => {
4248                self.tables.write().remove(table_id);
4249            }
4250            _ => {}
4251        }
4252        // Durable BEFORE return: the catalog checkpoint is the only local
4253        // durable record of the command (replicated catalog commands do not
4254        // ride the local WAL), and the state machine checkpoints right after.
4255        catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref())?;
4256        *self.catalog.write() = next_catalog;
4257        Ok(delta)
4258    }
4259
4260    /// Mount a table that a replicated catalog command created (Stage 2E):
4261    /// create the table directory and build the mounted table exactly like
4262    /// the create-table path does, minus the standalone WAL/commit side
4263    /// effects (the command itself is already durable in the raft log).
4264    fn mount_catalog_entry(&self, entry: &crate::catalog::CatalogEntry) -> Result<()> {
4265        let table_relative = Path::new(TABLES_DIR).join(entry.table_id.to_string());
4266        let table_root = Arc::new(
4267            self.durable_root
4268                .create_directory_all_pinned(&table_relative)?,
4269        );
4270        let tdir = table_root.io_path()?;
4271        let ctx = SharedCtx {
4272            root_guard: Some(table_root),
4273            epoch: Arc::clone(&self.epoch),
4274            page_cache: Arc::clone(&self.page_cache),
4275            decoded_cache: Arc::clone(&self.decoded_cache),
4276            snapshots: Arc::clone(&self.snapshots),
4277            kek: self.kek.clone(),
4278            commit_lock: Arc::clone(&self.commit_lock),
4279            shared: Some(crate::engine::SharedWalCtx {
4280                wal: Arc::clone(&self.shared_wal),
4281                group: Arc::clone(&self.group),
4282                poisoned: Arc::clone(&self.poisoned),
4283                txn_ids: Arc::clone(&self.next_txn_id),
4284                change_wake: self.change_wake.clone(),
4285                lifecycle: Arc::clone(&self.lifecycle),
4286            }),
4287            table_name: Some(entry.name.clone()),
4288            auth: self.table_auth_checker(),
4289            read_only: self.read_only,
4290        };
4291        let table = Table::create_in(&tdir, entry.schema.clone(), entry.table_id, ctx)?;
4292        self.tables
4293            .write()
4294            .insert(entry.table_id, TableHandle::new(table));
4295        Ok(())
4296    }
4297
4298    fn publish_catalog_candidate(
4299        &self,
4300        catalog: Catalog,
4301        epoch: Epoch,
4302        epoch_guard: &mut EpochGuard<'_>,
4303        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
4304    ) -> Result<()> {
4305        self.publish_catalog_candidate_with_prelude(
4306            catalog,
4307            epoch,
4308            epoch_guard,
4309            before_publish,
4310            Vec::new(),
4311        )
4312    }
4313
4314    fn publish_catalog_candidate_with_prelude(
4315        &self,
4316        catalog: Catalog,
4317        epoch: Epoch,
4318        epoch_guard: &mut EpochGuard<'_>,
4319        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
4320        prelude: Vec<(u64, crate::wal::Op)>,
4321    ) -> Result<()> {
4322        use crate::wal::DdlOp;
4323
4324        if self.read_only {
4325            return Err(MongrelError::ReadOnlyReplica);
4326        }
4327        if self.poisoned.load(Ordering::Relaxed) {
4328            return Err(MongrelError::Other(
4329                "database poisoned by fsync error".into(),
4330            ));
4331        }
4332        // S1A-004: admit the catalog publish as one core operation.
4333        let _operation = self.admit_operation()?;
4334        if let Some(before_publish) = before_publish.as_mut() {
4335            (**before_publish)()?;
4336        }
4337        if catalog.db_epoch != epoch.0 {
4338            return Err(MongrelError::InvalidArgument(format!(
4339                "catalog epoch {} does not match commit epoch {}",
4340                catalog.db_epoch, epoch.0
4341            )));
4342        }
4343        {
4344            let current = self.catalog.read();
4345            validate_catalog_transition(&current, &catalog)?;
4346        }
4347        validate_recovered_catalog(&catalog)?;
4348        let catalog_json = DdlOp::encode_catalog(&catalog)?;
4349        let txn_id = self.alloc_txn_id()?;
4350        let commit_seq = {
4351            let mut wal = self.shared_wal.lock();
4352            let append: Result<u64> = (|| {
4353                for (table_id, op) in prelude {
4354                    wal.append(txn_id, table_id, op)?;
4355                }
4356                wal.append(
4357                    txn_id,
4358                    WAL_TABLE_ID,
4359                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
4360                )?;
4361                wal.append_commit(txn_id, epoch, &[])
4362            })();
4363            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4364        };
4365        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4366        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
4367        self.finish_durable_publish(epoch, epoch_guard, &receipt, checkpoint)
4368    }
4369
4370    /// A WAL commit is already durable. Publish the matching catalog in memory
4371    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
4372    /// while the live handle must never continue with pre-commit metadata.
4373    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
4374        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
4375        let version = catalog.security_version;
4376        let principal = self.principal.read().clone();
4377        let principal = if catalog.require_auth {
4378            principal.as_ref().and_then(|principal| {
4379                Self::resolve_bound_principal_from_catalog(&catalog, principal)
4380            })
4381        } else {
4382            principal
4383        };
4384        *self.catalog.write() = catalog;
4385        self.security_coordinator
4386            .version
4387            .store(version, Ordering::Release);
4388        self.auth_state
4389            .set_require_auth(self.catalog.read().require_auth);
4390        *self.principal.write() = principal.clone();
4391        self.auth_state.set_principal(principal);
4392        checkpoint
4393    }
4394
4395    fn finish_durable_publish(
4396        &self,
4397        epoch: Epoch,
4398        epoch_guard: &mut EpochGuard<'_>,
4399        receipt: &mongreldb_log::CommitReceipt,
4400        post_step: Result<()>,
4401    ) -> Result<()> {
4402        if let Err(error) = self.publish_committed(receipt, epoch) {
4403            // The commit marker is durable but runtime publication failed. The
4404            // epoch guard stays armed so the assigned ticket is abandoned (the
4405            // watermark skips it), and the live handle poisons exactly like any
4406            // other post-durable failure.
4407            self.poisoned.store(true, Ordering::Relaxed);
4408            self.lifecycle.poison();
4409            return Err(MongrelError::DurableCommit {
4410                epoch: epoch.0,
4411                message: error.to_string(),
4412            });
4413        }
4414        epoch_guard.disarm();
4415        match post_step {
4416            Ok(()) => Ok(()),
4417            Err(error) => {
4418                self.poisoned.store(true, Ordering::Relaxed);
4419                self.lifecycle.poison();
4420                Err(MongrelError::DurableCommit {
4421                    epoch: epoch.0,
4422                    message: error.to_string(),
4423                })
4424            }
4425        }
4426    }
4427
4428    /// Advance reader visibility for a committed epoch. Publication is gated on
4429    /// the commit log's receipt (spec §9.4, FND-004): visibility only ever
4430    /// covers commands the commit log acknowledged durable. The
4431    /// `commit.publish.before`/`commit.publish.after` fault hooks bracket the
4432    /// watermark advance (spec §9.6, FND-006).
4433    fn publish_committed(
4434        &self,
4435        receipt: &mongreldb_log::CommitReceipt,
4436        epoch: Epoch,
4437    ) -> Result<()> {
4438        debug_assert_eq!(
4439            receipt.log_position.index, epoch.0,
4440            "commit receipt position must match the published epoch"
4441        );
4442        mongreldb_fault::inject("commit.publish.before").map_err(crate::commit_log::fault_as_io)?;
4443        self.epoch.publish_in_order(epoch);
4444        mongreldb_fault::inject("commit.publish.after").map_err(crate::commit_log::fault_as_io)?;
4445        Ok(())
4446    }
4447
4448    /// Wait for a commit marker to reach stable storage and return the commit
4449    /// log's irrevocable receipt (spec §9.4, FND-004). A failed append/fsync
4450    /// acknowledgement is ambiguous, so poison the live handle and preserve
4451    /// the assigned epoch in a structured unknown-outcome error.
4452    ///
4453    /// Used by the DDL/maintenance commit paths, which do not pre-assign a
4454    /// commit timestamp: the receipt's `commit_ts` is allocated from the
4455    /// node's HLC clock at seal time.
4456    fn await_durable_commit(
4457        &self,
4458        txn_id: u64,
4459        commit_seq: u64,
4460        epoch: Epoch,
4461    ) -> Result<mongreldb_log::CommitReceipt> {
4462        match self
4463            .commit_log
4464            .seal_transaction(txn_id, epoch, commit_seq, None)
4465        {
4466            Ok(receipt) => {
4467                self.record_commit_ts(epoch, receipt.commit_ts);
4468                Ok(receipt)
4469            }
4470            Err(error) => {
4471                self.poisoned.store(true, Ordering::Relaxed);
4472                self.lifecycle.poison();
4473                Err(MongrelError::CommitOutcomeUnknown {
4474                    epoch: epoch.0,
4475                    message: error.to_string(),
4476                })
4477            }
4478        }
4479    }
4480
4481    /// [`Self::await_durable_commit`] for the transaction commit sequencer,
4482    /// which assigned `commit_ts` under the sequencer lock (S1B-004 step 5,
4483    /// spec §8.2). The receipt carries that exact timestamp, matching the
4484    /// durable `Op::CommitTimestamp` ledger record written at append.
4485    fn await_durable_commit_with_ts(
4486        &self,
4487        txn_id: u64,
4488        commit_seq: u64,
4489        epoch: Epoch,
4490        commit_ts: mongreldb_types::hlc::HlcTimestamp,
4491    ) -> Result<mongreldb_log::CommitReceipt> {
4492        match self
4493            .commit_log
4494            .seal_transaction(txn_id, epoch, commit_seq, Some(commit_ts))
4495        {
4496            Ok(receipt) => {
4497                self.record_commit_ts(epoch, receipt.commit_ts);
4498                Ok(receipt)
4499            }
4500            Err(error) => {
4501                self.poisoned.store(true, Ordering::Relaxed);
4502                self.lifecycle.poison();
4503                Err(MongrelError::CommitOutcomeUnknown {
4504                    epoch: epoch.0,
4505                    message: error.to_string(),
4506                })
4507            }
4508        }
4509    }
4510
4511    /// Record one durable commit's receipt timestamp in the per-open ledger
4512    /// (bounded to the newest [`COMMIT_TS_LEDGER_CAP`] epochs). Called by the
4513    /// durability funnels once the commit log has issued the irrevocable
4514    /// receipt.
4515    fn record_commit_ts(&self, epoch: Epoch, commit_ts: mongreldb_types::hlc::HlcTimestamp) {
4516        let mut ledger = self.commit_ts_ledger.lock();
4517        ledger.insert(epoch.0, commit_ts);
4518        while ledger.len() > COMMIT_TS_LEDGER_CAP {
4519            ledger.pop_first();
4520        }
4521    }
4522
4523    /// The commit timestamp of a durable commit, by epoch — the literal
4524    /// write receipt behind the server's read-your-writes token (spec §8.2).
4525    ///
4526    /// Returns `Some` for commits sealed within this open (the exact receipt
4527    /// `HlcTimestamp`) and for commits recovered from the durable
4528    /// `Op::CommitTimestamp` WAL ledger at open; the latter reconstruct the
4529    /// physical component only, with `logical` and `node_tiebreaker` as 0 per
4530    /// the ledger byte format. Only the newest [`COMMIT_TS_LEDGER_CAP`]
4531    /// epochs are retained, and epochs sealed through `CommitLog::propose`
4532    /// (catalog-command proposals) are not recorded here within a live open;
4533    /// both miss shapes return `None`, and callers fall back to a fresh-begin
4534    /// HLC, which the single clock authority orders after every commit it has
4535    /// already issued.
4536    pub fn commit_ts_for_epoch(&self, epoch: Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp> {
4537        self.commit_ts_ledger.lock().get(&epoch.0).copied()
4538    }
4539
4540    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
4541        self.poisoned.store(true, Ordering::Relaxed);
4542        self.lifecycle.poison();
4543        MongrelError::CommitOutcomeUnknown {
4544            epoch: epoch.0,
4545            message: error.to_string(),
4546        }
4547    }
4548
4549    /// Persist a complete validated RLS/masking catalog through the WAL.
4550    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
4551        self.set_security_catalog_as_with_epoch(security, None)
4552            .map(|_| ())
4553    }
4554
4555    /// Persist security policy changes on behalf of an explicit request principal.
4556    pub fn set_security_catalog_as(
4557        &self,
4558        security: crate::security::SecurityCatalog,
4559        principal: Option<&crate::auth::Principal>,
4560    ) -> Result<()> {
4561        self.set_security_catalog_as_with_epoch(security, principal)
4562            .map(|_| ())
4563    }
4564
4565    /// Persist security policy changes and return the exact publication epoch.
4566    pub fn set_security_catalog_as_with_epoch(
4567        &self,
4568        security: crate::security::SecurityCatalog,
4569        principal: Option<&crate::auth::Principal>,
4570    ) -> Result<Epoch> {
4571        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
4572    }
4573
4574    /// Persist security policy changes, entering the commit fence immediately
4575    /// before the first WAL record can become visible to recovery.
4576    pub fn set_security_catalog_as_with_epoch_controlled<F>(
4577        &self,
4578        security: crate::security::SecurityCatalog,
4579        principal: Option<&crate::auth::Principal>,
4580        mut before_commit: F,
4581    ) -> Result<Epoch>
4582    where
4583        F: FnMut() -> Result<()>,
4584    {
4585        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
4586    }
4587
4588    fn set_security_catalog_as_with_epoch_inner(
4589        &self,
4590        security: crate::security::SecurityCatalog,
4591        principal: Option<&crate::auth::Principal>,
4592        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
4593    ) -> Result<Epoch> {
4594        use crate::wal::DdlOp;
4595        use std::sync::atomic::Ordering;
4596
4597        // S1F-001: the mutation is a versioned catalog command; its required
4598        // permission is checked against the caller principal first (identical
4599        // to the legacy hardcoded Admin gate).
4600        let command = crate::catalog_cmds::CatalogCommand::SetSecurityCatalog {
4601            security: security.clone(),
4602        };
4603        self.require_for(
4604            principal,
4605            &crate::catalog_cmds::required_permission(&command),
4606        )?;
4607        if self.poisoned.load(Ordering::Relaxed) {
4608            return Err(MongrelError::Other(
4609                "database poisoned by fsync error".into(),
4610            ));
4611        }
4612        // S1A-004: admit the security-catalog replacement as one core
4613        // operation.
4614        let _operation = self.admit_operation()?;
4615        let _ddl = self.ddl_lock.lock();
4616        // DDL serializes first; write-path order after that is security gate ->
4617        // commit lock -> shared WAL.
4618        let _security_write = self.security_write()?;
4619        self.require_for(
4620            principal,
4621            &crate::catalog_cmds::required_permission(&command),
4622        )?;
4623        let mut next_catalog = self.catalog.read().clone();
4624        validate_security_catalog(&next_catalog, &security)?;
4625        let payload = DdlOp::encode_security(&security)?;
4626        let _commit = self.commit_lock.lock();
4627        let epoch = self.epoch.bump_assigned();
4628        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4629        let txn_id = self.alloc_txn_id()?;
4630        self.apply_catalog_command_to(&mut next_catalog, command)?;
4631        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4632        let commit_seq = {
4633            let mut wal = self.shared_wal.lock();
4634            if let Some(before_commit) = before_commit {
4635                before_commit()?;
4636            }
4637            let append: Result<u64> = (|| {
4638                wal.append(
4639                    txn_id,
4640                    WAL_TABLE_ID,
4641                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
4642                        security_json: payload,
4643                    }),
4644                )?;
4645                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4646                wal.append_commit(txn_id, epoch, &[])
4647            })();
4648            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4649        };
4650        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4651        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4652        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
4653        Ok(epoch)
4654    }
4655
4656    pub fn require_for(
4657        &self,
4658        principal: Option<&crate::auth::Principal>,
4659        permission: &crate::auth::Permission,
4660    ) -> Result<()> {
4661        let Some(principal) = principal else {
4662            return self.require(permission);
4663        };
4664        let resolved;
4665        let principal = if self.auth_state.require_auth() || principal.user_id != 0 {
4666            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
4667                .ok_or(MongrelError::AuthRequired)?;
4668            &resolved
4669        } else {
4670            principal
4671        };
4672        #[cfg(test)]
4673        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
4674        if principal.has_permission(permission) {
4675            Ok(())
4676        } else {
4677            Err(MongrelError::PermissionDenied {
4678                required: permission.clone(),
4679                principal: principal.username.clone(),
4680            })
4681        }
4682    }
4683
4684    /// Recheck the exact operation principal while the caller holds the
4685    /// security gate. This deliberately performs no refresh or nested gate
4686    /// acquisition.
4687    fn require_exact_principal_current(
4688        &self,
4689        principal: Option<&crate::auth::Principal>,
4690        permission: &crate::auth::Permission,
4691    ) -> Result<()> {
4692        let catalog = self.catalog.read();
4693        if !catalog.require_auth {
4694            return Ok(());
4695        }
4696        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
4697        let current = Self::resolve_bound_principal_from_catalog(&catalog, supplied)
4698            .ok_or(MongrelError::AuthRequired)?;
4699        if current.has_permission(permission) {
4700            Ok(())
4701        } else {
4702            Err(MongrelError::PermissionDenied {
4703                required: permission.clone(),
4704                principal: current.username,
4705            })
4706        }
4707    }
4708
4709    pub(crate) fn with_exact_principal_current<T, F>(
4710        &self,
4711        principal: Option<&crate::auth::Principal>,
4712        permission: &crate::auth::Permission,
4713        operation: F,
4714    ) -> Result<T>
4715    where
4716        F: FnOnce() -> Result<T>,
4717    {
4718        let _security = self.security_coordinator.gate.read();
4719        self.require_exact_principal_current(principal, permission)?;
4720        operation()
4721    }
4722
4723    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
4724        self.principal.read().clone()
4725    }
4726
4727    #[cfg(test)]
4728    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
4729        *self.principal.write() = principal.clone();
4730        self.auth_state.set_principal(principal);
4731    }
4732
4733    pub fn require_columns_for(
4734        &self,
4735        table: &str,
4736        operation: crate::auth::ColumnOperation,
4737        column_ids: &[u16],
4738        principal: Option<&crate::auth::Principal>,
4739    ) -> Result<()> {
4740        if principal.is_none() && !self.auth_state.require_auth() {
4741            return Ok(());
4742        }
4743        let cached = self.principal.read().clone();
4744        let principal = principal.or(cached.as_ref());
4745        let Some(principal) = principal else {
4746            let permission = match operation {
4747                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
4748                    table: table.to_string(),
4749                },
4750                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
4751                    table: table.to_string(),
4752                },
4753                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
4754                    table: table.to_string(),
4755                },
4756            };
4757            return self.require(&permission);
4758        };
4759        let catalog = self.catalog.read();
4760        let resolved;
4761        let principal = if catalog.require_auth || principal.user_id != 0 {
4762            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
4763                .ok_or(MongrelError::AuthRequired)?;
4764            &resolved
4765        } else {
4766            principal
4767        };
4768        let schema = &catalog
4769            .live(table)
4770            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
4771            .schema;
4772        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
4773    }
4774
4775    fn require_columns_for_principal(
4776        table: &str,
4777        schema: &Schema,
4778        operation: crate::auth::ColumnOperation,
4779        column_ids: &[u16],
4780        principal: &crate::auth::Principal,
4781    ) -> Result<()> {
4782        #[cfg(test)]
4783        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
4784        match principal.column_access(table, operation) {
4785            crate::auth::ColumnAccess::All => Ok(()),
4786            crate::auth::ColumnAccess::Columns(allowed) => {
4787                let denied = column_ids.iter().find_map(|column_id| {
4788                    schema
4789                        .columns
4790                        .iter()
4791                        .find(|column| column.id == *column_id)
4792                        .filter(|column| !allowed.contains(&column.name))
4793                });
4794                if denied.is_none() {
4795                    Ok(())
4796                } else {
4797                    Err(MongrelError::PermissionDenied {
4798                        required: match operation {
4799                            crate::auth::ColumnOperation::Select => {
4800                                crate::auth::Permission::SelectColumns {
4801                                    table: table.to_string(),
4802                                    columns: denied
4803                                        .into_iter()
4804                                        .map(|column| column.name.clone())
4805                                        .collect(),
4806                                }
4807                            }
4808                            crate::auth::ColumnOperation::Insert => {
4809                                crate::auth::Permission::InsertColumns {
4810                                    table: table.to_string(),
4811                                    columns: denied
4812                                        .into_iter()
4813                                        .map(|column| column.name.clone())
4814                                        .collect(),
4815                                }
4816                            }
4817                            crate::auth::ColumnOperation::Update => {
4818                                crate::auth::Permission::UpdateColumns {
4819                                    table: table.to_string(),
4820                                    columns: denied
4821                                        .into_iter()
4822                                        .map(|column| column.name.clone())
4823                                        .collect(),
4824                                }
4825                            }
4826                        },
4827                        principal: principal.username.clone(),
4828                    })
4829                }
4830            }
4831            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
4832                required: match operation {
4833                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
4834                        table: table.to_string(),
4835                    },
4836                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
4837                        table: table.to_string(),
4838                    },
4839                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
4840                        table: table.to_string(),
4841                    },
4842                },
4843                principal: principal.username.clone(),
4844            }),
4845        }
4846    }
4847
4848    pub fn select_column_ids_for(
4849        &self,
4850        table: &str,
4851        principal: Option<&crate::auth::Principal>,
4852    ) -> Result<Vec<u16>> {
4853        let catalog = self.catalog.read();
4854        let columns = catalog
4855            .live(table)
4856            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
4857            .schema
4858            .columns
4859            .iter()
4860            .map(|column| (column.id, column.name.clone()))
4861            .collect::<Vec<_>>();
4862        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
4863        drop(catalog);
4864        let Some(principal) = principal.as_ref() else {
4865            self.require(&crate::auth::Permission::Select {
4866                table: table.to_string(),
4867            })?;
4868            return Ok(columns.iter().map(|(id, _)| *id).collect());
4869        };
4870        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
4871            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
4872            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
4873                .iter()
4874                .filter(|(_, name)| allowed.contains(name))
4875                .map(|(id, _)| *id)
4876                .collect()),
4877            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
4878                required: crate::auth::Permission::Select {
4879                    table: table.to_string(),
4880                },
4881                principal: principal.username.clone(),
4882            }),
4883        }
4884    }
4885
4886    pub fn secure_rows_for(
4887        &self,
4888        table: &str,
4889        rows: Vec<crate::memtable::Row>,
4890        principal: Option<&crate::auth::Principal>,
4891    ) -> Result<Vec<crate::memtable::Row>> {
4892        self.secure_rows_for_with_context(table, rows, principal, None)
4893    }
4894
4895    pub fn secure_rows_for_with_context(
4896        &self,
4897        table: &str,
4898        rows: Vec<crate::memtable::Row>,
4899        principal: Option<&crate::auth::Principal>,
4900        context: Option<&crate::query::AiExecutionContext>,
4901    ) -> Result<Vec<crate::memtable::Row>> {
4902        let (security, principal) = {
4903            let catalog = self.catalog.read();
4904            (
4905                catalog.security.clone(),
4906                self.principal_for_authorized_read(&catalog, principal, false)?,
4907            )
4908        };
4909        if !security.table_has_security(table) {
4910            return Ok(rows);
4911        }
4912        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
4913        let mut output = Vec::new();
4914        for mut row in rows {
4915            if let Some(context) = context {
4916                context.consume(1)?;
4917            }
4918            if security.row_allowed(
4919                table,
4920                crate::security::PolicyCommand::Select,
4921                &row,
4922                principal,
4923                false,
4924            ) {
4925                security.apply_masks(table, &mut row, principal);
4926                output.push(row);
4927            }
4928        }
4929        Ok(output)
4930    }
4931
4932    /// Apply column masks to already RLS-authorized scored hits without a
4933    /// second row gather or policy evaluation.
4934    pub fn mask_search_hits_for(
4935        &self,
4936        table: &str,
4937        hits: &mut [crate::query::SearchHit],
4938        principal: Option<&crate::auth::Principal>,
4939    ) -> Result<()> {
4940        let (security, principal) = {
4941            let catalog = self.catalog.read();
4942            (
4943                catalog.security.clone(),
4944                self.principal_for_authorized_read(&catalog, principal, false)?,
4945            )
4946        };
4947        if !security.table_has_security(table) {
4948            return Ok(());
4949        }
4950        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
4951        for hit in hits {
4952            security.apply_masks_to_cells(table, &mut hit.cells, principal);
4953        }
4954        Ok(())
4955    }
4956
4957    /// Apply masks to rows already admitted by candidate-aware RLS.
4958    pub fn mask_rows_for(
4959        &self,
4960        table: &str,
4961        rows: &mut [crate::memtable::Row],
4962        principal: Option<&crate::auth::Principal>,
4963    ) -> Result<()> {
4964        let (security, principal) = {
4965            let catalog = self.catalog.read();
4966            (
4967                catalog.security.clone(),
4968                self.principal_for_authorized_read(&catalog, principal, false)?,
4969            )
4970        };
4971        if !security.table_has_security(table) {
4972            return Ok(());
4973        }
4974        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
4975        for row in rows {
4976            security.apply_masks(table, row, principal);
4977        }
4978        Ok(())
4979    }
4980
4981    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
4982    pub fn authorized_candidate_ids_for(
4983        &self,
4984        table: &str,
4985        principal: Option<&crate::auth::Principal>,
4986    ) -> Result<Option<std::collections::HashSet<RowId>>> {
4987        Ok(self
4988            .authorized_read_snapshot(table, principal)?
4989            .allowed_row_ids)
4990    }
4991
4992    fn allowed_row_ids_locked(
4993        &self,
4994        table_name: &str,
4995        table: &Table,
4996        table_snapshot: Snapshot,
4997        security_state: (&crate::security::SecurityCatalog, u64),
4998        principal: Option<&crate::auth::Principal>,
4999        context: Option<&crate::query::AiExecutionContext>,
5000    ) -> Result<Option<Arc<HashSet<RowId>>>> {
5001        let (security, security_version) = security_state;
5002        if !security.rls_enabled(table_name) {
5003            return Ok(None);
5004        }
5005        let authorization_started = std::time::Instant::now();
5006        let principal = principal.ok_or(MongrelError::AuthRequired)?;
5007        let mut roles = principal.roles.clone();
5008        roles.sort_unstable();
5009        let principal_key = format!(
5010            "{}:{}:{}:{}:{roles:?}",
5011            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
5012        );
5013        let cache_key = (
5014            table_name.to_string(),
5015            table.data_generation(),
5016            security_version,
5017            principal_key,
5018        );
5019        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
5020            crate::trace::QueryTrace::record(|trace| {
5021                trace.rls_cache_hit = true;
5022                trace.authorization_nanos = trace
5023                    .authorization_nanos
5024                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5025            });
5026            return Ok(Some(allowed));
5027        }
5028        if let Some(context) = context {
5029            context.checkpoint()?;
5030        }
5031        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
5032        let started = std::time::Instant::now();
5033        let rows = table.visible_rows(table_snapshot)?;
5034        let rows_evaluated = rows.len() as u64;
5035        let mut allowed = HashSet::new();
5036        for chunk in rows.chunks(256) {
5037            if let Some(context) = context {
5038                context.consume(chunk.len())?;
5039            }
5040            allowed.extend(chunk.iter().filter_map(|row| {
5041                security
5042                    .row_allowed(
5043                        table_name,
5044                        crate::security::PolicyCommand::Select,
5045                        row,
5046                        principal,
5047                        false,
5048                    )
5049                    .then_some(row.row_id)
5050            }));
5051        }
5052        let allowed = Arc::new(allowed);
5053        let mut cache = self.rls_cache.lock();
5054        cache.build_nanos = cache
5055            .build_nanos
5056            .saturating_add(started.elapsed().as_nanos() as u64);
5057        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
5058        cache.insert(cache_key, Arc::clone(&allowed));
5059        crate::trace::QueryTrace::record(|trace| {
5060            trace.rls_rows_evaluated = trace
5061                .rls_rows_evaluated
5062                .saturating_add(rows_evaluated as usize);
5063            trace.authorization_nanos = trace
5064                .authorization_nanos
5065                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5066        });
5067        Ok(Some(allowed))
5068    }
5069
5070    fn principal_for_authorized_read(
5071        &self,
5072        catalog: &Catalog,
5073        principal: Option<&crate::auth::Principal>,
5074        catalog_bound: bool,
5075    ) -> Result<Option<crate::auth::Principal>> {
5076        let principal = principal.cloned().or_else(|| self.principal.read().clone());
5077        let Some(principal) = principal else {
5078            return Ok(None);
5079        };
5080        if catalog.require_auth || catalog_bound || principal.user_id != 0 {
5081            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
5082                .map(Some)
5083                .ok_or(MongrelError::AuthRequired);
5084        }
5085        Ok(Some(principal))
5086    }
5087
5088    /// Run authorization, candidate generation, ranking, and materialization
5089    /// while holding one table generation. Security changes cause a bounded
5090    /// retry before any result is published.
5091    pub fn with_authorized_read<T, F>(
5092        &self,
5093        table_name: &str,
5094        principal: Option<&crate::auth::Principal>,
5095        catalog_bound: bool,
5096        read: F,
5097    ) -> Result<T>
5098    where
5099        F: FnMut(
5100            &mut Table,
5101            Snapshot,
5102            Option<&HashSet<RowId>>,
5103            Option<&crate::auth::Principal>,
5104        ) -> Result<T>,
5105    {
5106        self.with_authorized_read_context(
5107            table_name,
5108            principal,
5109            catalog_bound,
5110            None,
5111            None,
5112            None,
5113            read,
5114        )
5115    }
5116
5117    #[allow(clippy::too_many_arguments)]
5118    pub fn with_authorized_read_context<T, F>(
5119        &self,
5120        table_name: &str,
5121        principal: Option<&crate::auth::Principal>,
5122        catalog_bound: bool,
5123        authorization: Option<&ReadAuthorization>,
5124        context: Option<&crate::query::AiExecutionContext>,
5125        snapshot_override: Option<Snapshot>,
5126        read: F,
5127    ) -> Result<T>
5128    where
5129        F: FnMut(
5130            &mut Table,
5131            Snapshot,
5132            Option<&HashSet<RowId>>,
5133            Option<&crate::auth::Principal>,
5134        ) -> Result<T>,
5135    {
5136        self.with_authorized_read_context_stamped(
5137            table_name,
5138            principal,
5139            catalog_bound,
5140            authorization,
5141            context,
5142            snapshot_override,
5143            read,
5144        )
5145        .map(|(result, _)| result)
5146    }
5147
5148    #[allow(clippy::too_many_arguments)]
5149    pub fn with_authorized_read_context_stamped<T, F>(
5150        &self,
5151        table_name: &str,
5152        principal: Option<&crate::auth::Principal>,
5153        catalog_bound: bool,
5154        authorization: Option<&ReadAuthorization>,
5155        context: Option<&crate::query::AiExecutionContext>,
5156        snapshot_override: Option<Snapshot>,
5157        mut read: F,
5158    ) -> Result<(T, AuthorizedReadStamp)>
5159    where
5160        F: FnMut(
5161            &mut Table,
5162            Snapshot,
5163            Option<&HashSet<RowId>>,
5164            Option<&crate::auth::Principal>,
5165        ) -> Result<T>,
5166    {
5167        if principal.is_none() && self.principal.read().is_some() {
5168            self.refresh_principal()?;
5169        }
5170        const RETRIES: usize = 3;
5171        let handle = self.table(table_name)?;
5172        for attempt in 0..RETRIES {
5173            crate::trace::QueryTrace::record(|trace| {
5174                trace.authorization_retries = attempt;
5175            });
5176            let (security, security_version, effective_principal) = {
5177                let catalog = self.catalog.read();
5178                (
5179                    catalog.security.clone(),
5180                    catalog.security_version,
5181                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
5182                )
5183            };
5184            if let Some(authorization) = authorization {
5185                for permission in &authorization.permissions {
5186                    self.require_for(effective_principal.as_ref(), permission)?;
5187                }
5188                self.require_columns_for(
5189                    table_name,
5190                    authorization.operation,
5191                    &authorization.columns,
5192                    effective_principal.as_ref(),
5193                )?;
5194            }
5195            let result = {
5196                let mut table = lock_table_with_context(&handle, context)?;
5197                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
5198                let allowed = self.allowed_row_ids_locked(
5199                    table_name,
5200                    &table,
5201                    snapshot,
5202                    (&security, security_version),
5203                    effective_principal.as_ref(),
5204                    context,
5205                )?;
5206                let stamp = AuthorizedReadStamp {
5207                    table_id: table.table_id(),
5208                    schema_id: table.schema().schema_id,
5209                    data_generation: table.data_generation(),
5210                    security_version,
5211                    snapshot,
5212                };
5213                let result = read(
5214                    &mut table,
5215                    snapshot,
5216                    allowed.as_deref(),
5217                    effective_principal.as_ref(),
5218                )?;
5219                (result, stamp)
5220            };
5221            if let Some(context) = context {
5222                context.checkpoint()?;
5223            }
5224            if self.catalog.read().security_version == security_version {
5225                return Ok(result);
5226            }
5227            if attempt + 1 == RETRIES {
5228                return Err(MongrelError::Conflict(
5229                    "security policy changed during scored read".into(),
5230                ));
5231            }
5232        }
5233        Err(MongrelError::Conflict(
5234            "authorization retry loop exhausted".into(),
5235        ))
5236    }
5237
5238    fn with_authorized_aggregate_table<T, F>(
5239        &self,
5240        table_name: &str,
5241        columns: &[u16],
5242        principal: Option<&crate::auth::Principal>,
5243        catalog_bound: bool,
5244        allow_table_security: bool,
5245        mut aggregate: F,
5246    ) -> Result<T>
5247    where
5248        F: FnMut(
5249            &mut Table,
5250            Option<&crate::security::CandidateAuthorization<'_>>,
5251            Option<&crate::auth::Principal>,
5252            u64,
5253        ) -> Result<T>,
5254    {
5255        if principal.is_none() && self.principal.read().is_some() {
5256            self.refresh_principal()?;
5257        }
5258        const RETRIES: usize = 3;
5259        let handle = self.table(table_name)?;
5260        for attempt in 0..RETRIES {
5261            let (security, security_version, effective_principal) = {
5262                let catalog = self.catalog.read();
5263                (
5264                    catalog.security.clone(),
5265                    catalog.security_version,
5266                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
5267                )
5268            };
5269            self.require_columns_for(
5270                table_name,
5271                crate::auth::ColumnOperation::Select,
5272                columns,
5273                effective_principal.as_ref(),
5274            )?;
5275            if !allow_table_security && security.table_has_security(table_name) {
5276                return Err(MongrelError::InvalidArgument(
5277                    "incremental aggregate is unsupported while RLS or column masks are active"
5278                        .into(),
5279                ));
5280            }
5281            let result = {
5282                let mut table = handle.lock();
5283                let authorization = if security.rls_enabled(table_name) {
5284                    Some(crate::security::CandidateAuthorization {
5285                        table: table_name,
5286                        security: &security,
5287                        principal: effective_principal
5288                            .as_ref()
5289                            .ok_or(MongrelError::AuthRequired)?,
5290                    })
5291                } else {
5292                    None
5293                };
5294                aggregate(
5295                    &mut table,
5296                    authorization.as_ref(),
5297                    effective_principal.as_ref(),
5298                    security_version,
5299                )?
5300            };
5301            if self.catalog.read().security_version == security_version {
5302                return Ok(result);
5303            }
5304            if attempt + 1 == RETRIES {
5305                return Err(MongrelError::Conflict(
5306                    "security policy changed during aggregate read".into(),
5307                ));
5308            }
5309        }
5310        Err(MongrelError::Conflict(
5311            "aggregate authorization retry loop exhausted".into(),
5312        ))
5313    }
5314
5315    /// Scored-read authorization that evaluates RLS only for approximate
5316    /// candidates. This avoids a full-table policy scan on cache misses while
5317    /// preserving one table generation and security-version retry.
5318    pub fn with_authorized_scored_read_context<T, F>(
5319        &self,
5320        table_name: &str,
5321        principal: Option<&crate::auth::Principal>,
5322        catalog_bound: bool,
5323        authorization: Option<&ReadAuthorization>,
5324        context: Option<&crate::query::AiExecutionContext>,
5325        mut read: F,
5326    ) -> Result<T>
5327    where
5328        F: FnMut(
5329            &mut Table,
5330            Snapshot,
5331            Option<&crate::security::CandidateAuthorization<'_>>,
5332            Option<&crate::auth::Principal>,
5333        ) -> Result<T>,
5334    {
5335        self.with_authorized_scored_read_context_at(
5336            table_name,
5337            principal,
5338            catalog_bound,
5339            authorization,
5340            context,
5341            None,
5342            |table, snapshot, authorization, principal| {
5343                let mut table = table.clone();
5344                read(&mut table, snapshot, authorization, principal)
5345            },
5346        )
5347    }
5348
5349    #[allow(clippy::too_many_arguments)]
5350    pub fn with_authorized_scored_read_context_at<T, F>(
5351        &self,
5352        table_name: &str,
5353        principal: Option<&crate::auth::Principal>,
5354        catalog_bound: bool,
5355        authorization: Option<&ReadAuthorization>,
5356        context: Option<&crate::query::AiExecutionContext>,
5357        snapshot_override: Option<Snapshot>,
5358        read: F,
5359    ) -> Result<T>
5360    where
5361        F: FnMut(
5362            &Table,
5363            Snapshot,
5364            Option<&crate::security::CandidateAuthorization<'_>>,
5365            Option<&crate::auth::Principal>,
5366        ) -> Result<T>,
5367    {
5368        self.with_authorized_scored_read_context_at_stamped(
5369            table_name,
5370            principal,
5371            catalog_bound,
5372            authorization,
5373            context,
5374            snapshot_override,
5375            read,
5376        )
5377        .map(|(result, _)| result)
5378    }
5379
5380    #[allow(clippy::too_many_arguments)]
5381    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
5382        &self,
5383        table_name: &str,
5384        principal: Option<&crate::auth::Principal>,
5385        catalog_bound: bool,
5386        authorization: Option<&ReadAuthorization>,
5387        context: Option<&crate::query::AiExecutionContext>,
5388        snapshot_override: Option<Snapshot>,
5389        mut read: F,
5390    ) -> Result<(T, AuthorizedReadStamp)>
5391    where
5392        F: FnMut(
5393            &Table,
5394            Snapshot,
5395            Option<&crate::security::CandidateAuthorization<'_>>,
5396            Option<&crate::auth::Principal>,
5397        ) -> Result<T>,
5398    {
5399        if principal.is_none() && self.principal.read().is_some() {
5400            self.refresh_principal()?;
5401        }
5402        const RETRIES: usize = 3;
5403        let handle = self.table(table_name)?;
5404        for attempt in 0..RETRIES {
5405            if let Some(context) = context {
5406                context.checkpoint()?;
5407            }
5408            crate::trace::QueryTrace::record(|trace| {
5409                trace.authorization_retries = attempt;
5410            });
5411            let (security, security_version, effective_principal) = {
5412                let catalog = self.catalog.read();
5413                (
5414                    catalog.security.clone(),
5415                    catalog.security_version,
5416                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
5417                )
5418            };
5419            if let Some(authorization) = authorization {
5420                for permission in &authorization.permissions {
5421                    self.require_for(effective_principal.as_ref(), permission)?;
5422                }
5423                self.require_columns_for(
5424                    table_name,
5425                    authorization.operation,
5426                    &authorization.columns,
5427                    effective_principal.as_ref(),
5428                )?;
5429            }
5430            let result = {
5431                let (table, snapshot, _snapshot_guard, _run_pins) =
5432                    self.scored_read_generation(&handle, context, snapshot_override)?;
5433                let candidate_authorization = if security.rls_enabled(table_name) {
5434                    Some(crate::security::CandidateAuthorization {
5435                        table: table_name,
5436                        security: &security,
5437                        principal: effective_principal
5438                            .as_ref()
5439                            .ok_or(MongrelError::AuthRequired)?,
5440                    })
5441                } else {
5442                    None
5443                };
5444                let stamp = AuthorizedReadStamp {
5445                    table_id: table.table_id(),
5446                    schema_id: table.schema().schema_id,
5447                    data_generation: table.data_generation(),
5448                    security_version,
5449                    snapshot,
5450                };
5451                let result = read(
5452                    table.as_ref(),
5453                    snapshot,
5454                    candidate_authorization.as_ref(),
5455                    effective_principal.as_ref(),
5456                )?;
5457                (result, stamp)
5458            };
5459            if let Some(context) = context {
5460                context.checkpoint()?;
5461            }
5462            if self.catalog.read().security_version == security_version {
5463                return Ok(result);
5464            }
5465            if attempt + 1 == RETRIES {
5466                return Err(MongrelError::Conflict(
5467                    "security policy changed during scored read".into(),
5468                ));
5469            }
5470        }
5471        Err(MongrelError::Conflict(
5472            "scored-read authorization retry loop exhausted".into(),
5473        ))
5474    }
5475
5476    fn scored_read_generation(
5477        &self,
5478        handle: &TableHandle,
5479        context: Option<&crate::query::AiExecutionContext>,
5480        snapshot_override: Option<Snapshot>,
5481    ) -> Result<(
5482        Arc<TableReadGeneration>,
5483        Snapshot,
5484        crate::retention::OwnedSnapshotGuard,
5485        RunPins,
5486    )> {
5487        let mut table = if let Some(context) = context {
5488            loop {
5489                context.checkpoint()?;
5490                let wait = context
5491                    .remaining_duration()
5492                    .unwrap_or(std::time::Duration::from_millis(5))
5493                    .min(std::time::Duration::from_millis(5));
5494                if let Some(table) = handle.try_lock_for(wait) {
5495                    break table;
5496                }
5497            }
5498        } else {
5499            handle.lock()
5500        };
5501        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
5502            self.snapshot_at_owned(snapshot.epoch)?
5503        } else {
5504            let snapshot = table.snapshot();
5505            let guard = self.snapshots.register_owned(snapshot.epoch);
5506            (snapshot, guard)
5507        };
5508        let table_id = table.table_id();
5509        let run_keys: Vec<_> = table
5510            .active_run_ids()
5511            .map(|run_id| (table_id, run_id))
5512            .collect();
5513        let generation = handle
5514            .generation_metrics
5515            .activate(table.clone_read_generation()?);
5516        let run_pins = self.pin_runs(&run_keys);
5517        Ok((generation, snapshot, snapshot_guard, run_pins))
5518    }
5519
5520    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
5521        let mut pins = self.backup_pins.lock();
5522        for run in runs {
5523            *pins.entry(*run).or_insert(0) += 1;
5524        }
5525        drop(pins);
5526        RunPins {
5527            pins: Arc::clone(&self.backup_pins),
5528            runs: runs.to_vec(),
5529        }
5530    }
5531
5532    /// Execute a native conjunctive read with the database principal's row
5533    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
5534    /// policy-unaware; language bindings must use this boundary for reads.
5535    pub fn query_for_current_principal(
5536        &self,
5537        table_name: &str,
5538        query: &crate::query::Query,
5539        projection: Option<&[u16]>,
5540    ) -> Result<Vec<crate::memtable::Row>> {
5541        let condition_columns = crate::query::condition_columns(&query.conditions);
5542        self.with_authorized_read(
5543            table_name,
5544            None,
5545            true,
5546            |table, snapshot, allowed, principal| {
5547                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5548                self.require_columns_for(
5549                    table_name,
5550                    crate::auth::ColumnOperation::Select,
5551                    &condition_columns,
5552                    principal,
5553                )?;
5554                if let Some(projection) = projection {
5555                    self.require_columns_for(
5556                        table_name,
5557                        crate::auth::ColumnOperation::Select,
5558                        projection,
5559                        principal,
5560                    )?;
5561                }
5562                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
5563                let projection =
5564                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
5565                for row in &mut rows {
5566                    row.columns.retain(|column, _| {
5567                        allowed_columns.contains(column)
5568                            && projection
5569                                .as_ref()
5570                                .is_none_or(|projection| projection.contains(column))
5571                    });
5572                }
5573                self.secure_rows_for(table_name, rows, principal)
5574            },
5575        )
5576    }
5577
5578    /// Execute a secured native read with cooperative cancellation across
5579    /// authorization, candidate generation, materialization, masking, and
5580    /// projection.
5581    pub fn query_for_current_principal_controlled(
5582        &self,
5583        table_name: &str,
5584        query: &crate::query::Query,
5585        projection: Option<&[u16]>,
5586        control: &crate::ExecutionControl,
5587    ) -> Result<Vec<crate::memtable::Row>> {
5588        self.query_for_principal_controlled(table_name, query, projection, None, true, control)
5589    }
5590
5591    fn query_for_principal_controlled(
5592        &self,
5593        table_name: &str,
5594        query: &crate::query::Query,
5595        projection: Option<&[u16]>,
5596        principal: Option<&crate::auth::Principal>,
5597        catalog_bound: bool,
5598        control: &crate::ExecutionControl,
5599    ) -> Result<Vec<crate::memtable::Row>> {
5600        control.checkpoint()?;
5601        let context = crate::query::AiExecutionContext::with_control(
5602            control.clone(),
5603            usize::MAX,
5604            crate::query::MAX_FUSED_CANDIDATES,
5605        );
5606        let condition_columns = crate::query::condition_columns(&query.conditions);
5607        self.with_authorized_read_context(
5608            table_name,
5609            principal,
5610            catalog_bound,
5611            None,
5612            Some(&context),
5613            None,
5614            |table, snapshot, allowed, principal| {
5615                control.checkpoint()?;
5616                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5617                self.require_columns_for(
5618                    table_name,
5619                    crate::auth::ColumnOperation::Select,
5620                    &condition_columns,
5621                    principal,
5622                )?;
5623                if let Some(projection) = projection {
5624                    self.require_columns_for(
5625                        table_name,
5626                        crate::auth::ColumnOperation::Select,
5627                        projection,
5628                        principal,
5629                    )?;
5630                }
5631                let rows =
5632                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
5633                let projection =
5634                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
5635                let mut projected = Vec::with_capacity(rows.len());
5636                for (index, mut row) in rows.into_iter().enumerate() {
5637                    if index & 255 == 0 {
5638                        control.checkpoint()?;
5639                    }
5640                    row.columns.retain(|column, _| {
5641                        allowed_columns.contains(column)
5642                            && projection
5643                                .as_ref()
5644                                .is_none_or(|projection| projection.contains(column))
5645                    });
5646                    projected.push(row);
5647                }
5648                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
5649            },
5650        )
5651    }
5652
5653    /// Reservoir aggregate with column grants, RLS, masks, and security-version
5654    /// retry applied at the database boundary.
5655    pub fn approx_aggregate_for_current_principal(
5656        &self,
5657        table_name: &str,
5658        conditions: &[crate::query::Condition],
5659        column: Option<u16>,
5660        agg: crate::engine::ApproxAgg,
5661        z: f64,
5662    ) -> Result<Option<crate::engine::ApproxResult>> {
5663        if !z.is_finite() || z <= 0.0 {
5664            return Err(MongrelError::InvalidArgument(
5665                "z must be finite and > 0".into(),
5666            ));
5667        }
5668        let mut columns = crate::query::condition_columns(conditions);
5669        columns.extend(column);
5670        columns.sort_unstable();
5671        columns.dedup();
5672        self.with_authorized_aggregate_table(
5673            table_name,
5674            &columns,
5675            None,
5676            true,
5677            true,
5678            |table, authorization, _, _| {
5679                table.approx_aggregate_with_candidate_authorization(
5680                    conditions,
5681                    column,
5682                    agg,
5683                    z,
5684                    authorization,
5685                )
5686            },
5687        )
5688    }
5689
5690    /// Incremental aggregate over an append-only table. Active RLS or masks are
5691    /// rejected because the table-global delta cache cannot safely represent a
5692    /// secured row universe.
5693    pub fn incremental_aggregate_for_current_principal(
5694        &self,
5695        table_name: &str,
5696        conditions: &[crate::query::Condition],
5697        column: Option<u16>,
5698        agg: crate::engine::NativeAgg,
5699    ) -> Result<crate::engine::IncrementalAggResult> {
5700        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
5701    }
5702
5703    /// Incremental aggregate using an explicit request principal. A
5704    /// catalog-bound principal is re-resolved on every retry so live grants,
5705    /// revocations, RLS, and masks cannot reuse a stale cache entry.
5706    pub fn incremental_aggregate_for_principal(
5707        &self,
5708        table_name: &str,
5709        conditions: &[crate::query::Condition],
5710        column: Option<u16>,
5711        agg: crate::engine::NativeAgg,
5712        principal: Option<&crate::auth::Principal>,
5713        catalog_bound: bool,
5714    ) -> Result<crate::engine::IncrementalAggResult> {
5715        let mut columns = crate::query::condition_columns(conditions);
5716        columns.extend(column);
5717        columns.sort_unstable();
5718        columns.dedup();
5719        self.with_authorized_aggregate_table(
5720            table_name,
5721            &columns,
5722            principal,
5723            catalog_bound,
5724            false,
5725            |table, _, principal, security_version| {
5726                let cache_key = incremental_aggregate_cache_key(
5727                    table_name,
5728                    conditions,
5729                    column,
5730                    agg,
5731                    principal,
5732                    security_version,
5733                );
5734                table.aggregate_incremental(cache_key, conditions, column, agg)
5735            },
5736        )
5737    }
5738
5739    /// Read one row with the database principal's row policy, column grants,
5740    /// and masks applied.
5741    pub fn get_for_current_principal(
5742        &self,
5743        table_name: &str,
5744        row_id: RowId,
5745    ) -> Result<Option<crate::memtable::Row>> {
5746        self.with_authorized_read(
5747            table_name,
5748            None,
5749            true,
5750            |table, snapshot, allowed, principal| {
5751                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5752                let Some(row) = table.get(row_id, snapshot) else {
5753                    return Ok(None);
5754                };
5755                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
5756                    return Ok(None);
5757                }
5758                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
5759                if let Some(row) = rows.first_mut() {
5760                    row.columns
5761                        .retain(|column, _| allowed_columns.contains(column));
5762                }
5763                Ok(rows.pop())
5764            },
5765        )
5766    }
5767
5768    /// Run exact ANN reranking over only rows authorized for this database
5769    /// handle. The embedding column still requires normal column access.
5770    pub fn ann_rerank_for_current_principal(
5771        &self,
5772        table_name: &str,
5773        request: &crate::query::AnnRerankRequest,
5774    ) -> Result<Vec<crate::query::AnnRerankHit>> {
5775        self.with_authorized_scored_read_context_at(
5776            table_name,
5777            None,
5778            true,
5779            Some(&ReadAuthorization {
5780                operation: crate::auth::ColumnOperation::Select,
5781                columns: vec![request.column_id],
5782                permissions: Vec::new(),
5783            }),
5784            None,
5785            None,
5786            |table, snapshot, authorization, principal| {
5787                self.require_columns_for(
5788                    table_name,
5789                    crate::auth::ColumnOperation::Select,
5790                    &[request.column_id],
5791                    principal,
5792                )?;
5793                table.ann_rerank_at_with_candidate_authorization_on_generation(
5794                    request,
5795                    snapshot,
5796                    authorization,
5797                    None,
5798                )
5799            },
5800        )
5801    }
5802
5803    /// Run a hybrid scored search over only rows authorized for this database
5804    /// handle. Applies retriever column grants, RLS, and masks to the returned
5805    /// hits.
5806    pub fn search_for_current_principal(
5807        &self,
5808        table_name: &str,
5809        request: &crate::query::SearchRequest,
5810    ) -> Result<Vec<crate::query::SearchHit>> {
5811        let mut columns = crate::query::condition_columns(&request.must);
5812        for named in &request.retrievers {
5813            columns.push(named.retriever.column_id());
5814        }
5815        if let Some(crate::query::Rerank::ExactVector {
5816            embedding_column, ..
5817        }) = &request.rerank
5818        {
5819            columns.push(*embedding_column);
5820        }
5821        if let Some(proj) = &request.projection {
5822            columns.extend(proj);
5823        }
5824        columns.sort_unstable();
5825        columns.dedup();
5826        self.with_authorized_scored_read_context_at(
5827            table_name,
5828            None,
5829            true,
5830            Some(&ReadAuthorization {
5831                operation: crate::auth::ColumnOperation::Select,
5832                columns: columns.clone(),
5833                permissions: Vec::new(),
5834            }),
5835            None,
5836            None,
5837            |table, snapshot, authorization, principal| {
5838                self.require_columns_for(
5839                    table_name,
5840                    crate::auth::ColumnOperation::Select,
5841                    &columns,
5842                    principal,
5843                )?;
5844                let hits = table.search_at_with_candidate_authorization_on_generation(
5845                    request,
5846                    snapshot,
5847                    authorization,
5848                    None,
5849                )?;
5850                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5851                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
5852                for mut hit in hits {
5853                    let row = crate::memtable::Row {
5854                        row_id: hit.row_id,
5855                        committed_epoch: crate::Epoch(0),
5856                        columns: hit
5857                            .cells
5858                            .iter()
5859                            .cloned()
5860                            .collect::<std::collections::HashMap<u16, crate::Value>>(),
5861                        deleted: false,
5862                    };
5863                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
5864                    if let Some(mut row) = rows.pop() {
5865                        row.columns
5866                            .retain(|column, _| allowed_columns.contains(column));
5867                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
5868                        hit.cells.sort_by_key(|(column, _)| *column);
5869                        secured.push(hit);
5870                    }
5871                }
5872                Ok(secured)
5873            },
5874        )
5875    }
5876
5877    /// Capture one table snapshot and the security version used to authorize it.
5878    /// The caller must validate the returned version before publishing results.
5879    pub fn authorized_read_snapshot(
5880        &self,
5881        table: &str,
5882        principal: Option<&crate::auth::Principal>,
5883    ) -> Result<AuthorizedReadSnapshot> {
5884        let (security, security_version, effective_principal) = {
5885            let catalog = self.catalog.read();
5886            (
5887                catalog.security.clone(),
5888                catalog.security_version,
5889                self.principal_for_authorized_read(&catalog, principal, false)?,
5890            )
5891        };
5892        let handle = self.table(table)?;
5893        let (table_snapshot, data_generation, allowed_row_ids) = {
5894            let table_handle = handle.lock();
5895            let table_snapshot = table_handle.snapshot();
5896            let data_generation = table_handle.data_generation();
5897            let allowed = self.allowed_row_ids_locked(
5898                table,
5899                &table_handle,
5900                table_snapshot,
5901                (&security, security_version),
5902                effective_principal.as_ref(),
5903                None,
5904            )?;
5905            (
5906                table_snapshot,
5907                data_generation,
5908                allowed.map(|allowed| (*allowed).clone()),
5909            )
5910        };
5911        Ok(AuthorizedReadSnapshot {
5912            table: table.to_string(),
5913            table_snapshot,
5914            data_generation,
5915            security_version,
5916            allowed_row_ids,
5917        })
5918    }
5919
5920    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
5921        if self.catalog.read().security_version != snapshot.security_version {
5922            return false;
5923        }
5924        self.table(&snapshot.table)
5925            .ok()
5926            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
5927    }
5928
5929    pub fn rls_cache_stats(&self) -> RlsCacheStats {
5930        self.rls_cache.lock().stats()
5931    }
5932
5933    /// Read visible rows with column authorization, RLS, and masks applied.
5934    pub fn rows_for(
5935        &self,
5936        table: &str,
5937        principal: Option<&crate::auth::Principal>,
5938    ) -> Result<Vec<crate::memtable::Row>> {
5939        if principal.is_none() && self.principal.read().is_some() {
5940            self.refresh_principal()?;
5941        }
5942        let allowed = self.select_column_ids_for(table, principal)?;
5943        let handle = self.table(table)?;
5944        let rows = {
5945            let table = handle.lock();
5946            table.visible_rows(table.snapshot())?
5947        };
5948        let mut rows = self.secure_rows_for(table, rows, principal)?;
5949        for row in &mut rows {
5950            row.columns.retain(|column, _| allowed.contains(column));
5951        }
5952        Ok(rows)
5953    }
5954
5955    /// Historical rows use the current principal and security catalog against
5956    /// the row values visible at the requested snapshot.
5957    pub fn rows_at_epoch_for_current_principal(
5958        &self,
5959        table_name: &str,
5960        snapshot: Snapshot,
5961    ) -> Result<Vec<crate::memtable::Row>> {
5962        self.with_authorized_read_context(
5963            table_name,
5964            None,
5965            true,
5966            Some(&ReadAuthorization {
5967                operation: crate::auth::ColumnOperation::Select,
5968                columns: Vec::new(),
5969                permissions: Vec::new(),
5970            }),
5971            None,
5972            Some(snapshot),
5973            |table, snapshot, allowed, principal| {
5974                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5975                let mut rows = table.visible_rows(snapshot)?;
5976                if let Some(allowed) = allowed {
5977                    rows.retain(|row| allowed.contains(&row.row_id));
5978                }
5979                rows = self.secure_rows_for(table_name, rows, principal)?;
5980                for row in &mut rows {
5981                    row.columns
5982                        .retain(|column, _| allowed_columns.contains(column));
5983                }
5984                Ok(rows)
5985            },
5986        )
5987    }
5988
5989    /// Count rows visible to a principal without bypassing RLS.
5990    pub fn count_for(
5991        &self,
5992        table: &str,
5993        principal: Option<&crate::auth::Principal>,
5994    ) -> Result<u64> {
5995        if principal.is_none() && self.principal.read().is_some() {
5996            self.refresh_principal()?;
5997        }
5998        self.select_column_ids_for(table, principal)?;
5999        if self.security_active_for(table) {
6000            Ok(self.rows_for(table, principal)?.len() as u64)
6001        } else {
6002            Ok(self.table(table)?.lock().count())
6003        }
6004    }
6005
6006    /// Authorize and write one native-API row for an explicit principal.
6007    pub fn put_for(
6008        &self,
6009        table: &str,
6010        mut cells: Vec<(u16, crate::memtable::Value)>,
6011        principal: Option<&crate::auth::Principal>,
6012    ) -> Result<RowId> {
6013        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
6014        self.require_columns_for(
6015            table,
6016            crate::auth::ColumnOperation::Insert,
6017            &columns,
6018            principal,
6019        )?;
6020        let handle = self.table(table)?;
6021        let mut table_handle = handle.lock();
6022        table_handle.fill_auto_inc(&mut cells)?;
6023        table_handle.apply_defaults(&mut cells)?;
6024        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
6025        row.columns.extend(cells.iter().cloned());
6026        self.check_row_policy_for(
6027            table,
6028            crate::security::PolicyCommand::Insert,
6029            &row,
6030            true,
6031            principal,
6032        )?;
6033        table_handle.put(cells)
6034    }
6035
6036    pub fn check_row_policy_for(
6037        &self,
6038        table: &str,
6039        command: crate::security::PolicyCommand,
6040        row: &crate::memtable::Row,
6041        check_new: bool,
6042        principal: Option<&crate::auth::Principal>,
6043    ) -> Result<()> {
6044        let security = self.catalog.read().security.clone();
6045        if !security.rls_enabled(table) {
6046            return Ok(());
6047        }
6048        let cached = self.principal.read().clone();
6049        let principal = principal
6050            .or(cached.as_ref())
6051            .ok_or(MongrelError::AuthRequired)?;
6052        if security.row_allowed(table, command, row, principal, check_new) {
6053            return Ok(());
6054        }
6055        let required = match command {
6056            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
6057                table: table.to_string(),
6058            },
6059            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
6060                table: table.to_string(),
6061            },
6062            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
6063                table: table.to_string(),
6064            },
6065            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
6066                crate::auth::Permission::Delete {
6067                    table: table.to_string(),
6068                }
6069            }
6070        };
6071        Err(MongrelError::PermissionDenied {
6072            required,
6073            principal: principal.username.clone(),
6074        })
6075    }
6076
6077    /// Durably create or replace a materialized-view definition after its
6078    /// physical table has been populated.
6079    pub fn set_materialized_view(
6080        &self,
6081        definition: crate::catalog::MaterializedViewEntry,
6082    ) -> Result<()> {
6083        self.set_materialized_view_with_epoch(definition)
6084            .map(|_| ())
6085    }
6086
6087    /// Durably create or replace a materialized-view definition and return its epoch.
6088    pub fn set_materialized_view_with_epoch(
6089        &self,
6090        definition: crate::catalog::MaterializedViewEntry,
6091    ) -> Result<Epoch> {
6092        use crate::wal::DdlOp;
6093        use std::sync::atomic::Ordering;
6094
6095        let command = crate::catalog_cmds::CatalogCommand::CreateMaterializedView {
6096            definition: definition.clone(),
6097        };
6098        self.require(&crate::catalog_cmds::required_permission(&command))?;
6099        if self.poisoned.load(Ordering::Relaxed) {
6100            return Err(MongrelError::Other(
6101                "database poisoned by fsync error".into(),
6102            ));
6103        }
6104        if definition.name.is_empty() || definition.query.trim().is_empty() {
6105            return Err(MongrelError::InvalidArgument(
6106                "materialized view name and query must not be empty".into(),
6107            ));
6108        }
6109        // S1A-004: admit the materialized-view replacement as one core
6110        // operation.
6111        let _operation = self.admit_operation()?;
6112        let _ddl = self.ddl_lock.lock();
6113        let _security_write = self.security_write()?;
6114        self.require(&crate::catalog_cmds::required_permission(&command))?;
6115        // S1F-001: the backing-table check validates pure against the current
6116        // catalog first so it surfaces before an epoch is consumed, exactly
6117        // like the legacy pre-bump lookup.
6118        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
6119        let table_id = self
6120            .catalog
6121            .read()
6122            .live(&definition.name)
6123            .ok_or_else(|| {
6124                MongrelError::NotFound(format!(
6125                    "materialized view table {:?} not found",
6126                    definition.name
6127                ))
6128            })?
6129            .table_id;
6130        let definition_json = DdlOp::encode_materialized_view(&definition)?;
6131        let _commit = self.commit_lock.lock();
6132        let epoch = self.epoch.bump_assigned();
6133        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6134        let txn_id = self.alloc_txn_id()?;
6135        let mut next_catalog = self.catalog.read().clone();
6136        self.apply_catalog_command_to(&mut next_catalog, command)?;
6137        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
6138        let commit_seq = {
6139            let mut wal = self.shared_wal.lock();
6140            let append: Result<u64> = (|| {
6141                wal.append(
6142                    txn_id,
6143                    table_id,
6144                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
6145                        name: definition.name.clone(),
6146                        definition_json,
6147                    }),
6148                )?;
6149                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
6150                wal.append_commit(txn_id, epoch, &[])
6151            })();
6152            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
6153        };
6154        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
6155
6156        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
6157        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
6158        Ok(epoch)
6159    }
6160
6161    /// The filesystem root this database was opened/created at.
6162    pub fn root(&self) -> &Path {
6163        self.durable_root.canonical_path()
6164    }
6165
6166    /// Open a descriptor-pinned view of this database root for durable
6167    /// extension state such as server idempotency receipts.
6168    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
6169        Arc::clone(&self.durable_root)
6170    }
6171
6172    /// Domain-separated authentication key for server idempotency state.
6173    /// Encrypted databases derive it from the in-memory KEK. Plain databases
6174    /// return `None`; their server persists a random key under the pinned root.
6175    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
6176        self.kek
6177            .as_deref()
6178            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
6179    }
6180
6181    pub fn is_read_only_replica(&self) -> bool {
6182        self.read_only
6183    }
6184
6185    /// Reject reads whose backing state may require WAL recovery after a
6186    /// post-commit publication failure. Ordinary table/catalog state is made
6187    /// coherent before poison; file-backed external modules use this gate.
6188    pub fn ensure_consistent_read(&self) -> Result<()> {
6189        self.ensure_owner_process()?;
6190        if self.poisoned.load(Ordering::Relaxed) {
6191            return Err(MongrelError::Other(
6192                "database poisoned by post-commit failure; reopen required".into(),
6193            ));
6194        }
6195        Ok(())
6196    }
6197
6198    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
6199        self.replication_wal_retention_segments
6200            .store(segments, std::sync::atomic::Ordering::Relaxed);
6201    }
6202
6203    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
6204    /// direct table commits, compaction, and WAL append are quiesced while the
6205    /// file image is read. WAL records newer than manifests remain sufficient
6206    /// for recovery, so no flush or compaction is required.
6207    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
6208        let admin = crate::auth::Permission::Admin;
6209        self.require(&admin)?;
6210        // S1A-004: admit the bootstrap capture as one core operation.
6211        let _operation = self.admit_operation()?;
6212        let operation_principal = self.principal_snapshot();
6213        let _barrier = self.replication_barrier.write();
6214        let _ddl = self.ddl_lock.lock();
6215        let _security = self.security_coordinator.gate.read();
6216        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6217        let mut handles: Vec<_> = self
6218            .tables
6219            .read()
6220            .iter()
6221            .map(|(id, handle)| (*id, handle.clone()))
6222            .collect();
6223        handles.sort_by_key(|(id, _)| *id);
6224        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
6225        let _commit = self.commit_lock.lock();
6226        let mut wal = self.shared_wal.lock();
6227        wal.group_sync()?;
6228        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6229        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
6230        let epoch = records
6231            .iter()
6232            .filter_map(|record| match &record.op {
6233                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
6234                _ => None,
6235            })
6236            .max()
6237            .unwrap_or(0)
6238            .max(self.epoch.committed().0);
6239        let files = crate::replication::capture_files(&self.root)?;
6240        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
6241        drop(wal);
6242        Ok(crate::replication::ReplicationSnapshot::new(
6243            source_id, epoch, files,
6244        ))
6245    }
6246
6247    /// Create an online, directly-openable backup at `destination`.
6248    ///
6249    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
6250    /// mutable metadata, and pins the exact immutable runs named by the copied
6251    /// manifests. Writers resume while those runs stream into a sibling staging
6252    /// directory. A checksummed backup manifest is written last, then the stage
6253    /// is atomically renamed into place.
6254    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
6255        let control = crate::ExecutionControl::new(None);
6256        self.hot_backup_controlled(destination, &control, || true)
6257    }
6258
6259    pub(crate) fn hot_backup_to_durable_child(
6260        &self,
6261        parent: &crate::durable_file::DurableRoot,
6262        child: &Path,
6263        control: &crate::ExecutionControl,
6264    ) -> Result<crate::backup::BackupReport> {
6265        let mut components = child.components();
6266        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
6267            || components.next().is_some()
6268        {
6269            return Err(MongrelError::InvalidArgument(
6270                "durable backup child must be one relative path component".into(),
6271            ));
6272        }
6273        let destination_name = child.file_name().ok_or_else(|| {
6274            MongrelError::InvalidArgument("durable backup child has no filename".into())
6275        })?;
6276        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
6277        self.hot_backup_prepared(prepared, control, || true)
6278    }
6279
6280    /// Build a backup cooperatively, then invoke `before_publish` immediately
6281    /// before the staging directory is atomically renamed into place.
6282    #[doc(hidden)]
6283    pub fn hot_backup_controlled<F>(
6284        &self,
6285        destination: impl AsRef<Path>,
6286        control: &crate::ExecutionControl,
6287        before_publish: F,
6288    ) -> Result<crate::backup::BackupReport>
6289    where
6290        F: FnOnce() -> bool,
6291    {
6292        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
6293        self.hot_backup_prepared(prepared, control, before_publish)
6294    }
6295
6296    fn hot_backup_prepared<F>(
6297        &self,
6298        mut prepared: PreparedBackupDestination,
6299        control: &crate::ExecutionControl,
6300        before_publish: F,
6301    ) -> Result<crate::backup::BackupReport>
6302    where
6303        F: FnOnce() -> bool,
6304    {
6305        let admin = crate::auth::Permission::Admin;
6306        self.require(&admin)?;
6307        // S1A-004: admit the backup as one core operation; `shutdown()` waits
6308        // for it to drain instead of closing the core mid-copy.
6309        let _operation = self.admit_operation()?;
6310        let operation_principal = self.principal_snapshot();
6311        control.checkpoint()?;
6312        let destination = prepared.destination_path.clone();
6313        let mut before_publish = Some(before_publish);
6314
6315        let outcome = (|| {
6316            control.checkpoint()?;
6317            let barrier = self.replication_barrier.write();
6318            let ddl = self.ddl_lock.lock();
6319            let security = self.security_coordinator.gate.read();
6320            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6321            let mut handles: Vec<_> = self
6322                .tables
6323                .read()
6324                .iter()
6325                .map(|(id, handle)| (*id, handle.clone()))
6326                .collect();
6327            handles.sort_by_key(|(id, _)| *id);
6328            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
6329            let commit = self.commit_lock.lock();
6330            let mut wal = self.shared_wal.lock();
6331            wal.group_sync()?;
6332            let epoch = self.epoch.committed().0;
6333            let boundary_unix_nanos = current_unix_nanos();
6334
6335            let pin_nonce = std::time::SystemTime::now()
6336                .duration_since(std::time::UNIX_EPOCH)
6337                .unwrap_or_default()
6338                .as_nanos();
6339            let file_pin_root = self
6340                .root
6341                .join(META_DIR)
6342                .join("backup-pins")
6343                .join(format!("{}-{pin_nonce}", std::process::id()));
6344            std::fs::create_dir_all(&file_pin_root)?;
6345            let _file_pins = BackupFilePins {
6346                root: file_pin_root.clone(),
6347            };
6348            let mut run_files = Vec::new();
6349            for (index, (table_id, _)) in handles.iter().enumerate() {
6350                if index % 256 == 0 {
6351                    control.checkpoint()?;
6352                }
6353                let table = &table_guards[index];
6354                for (run_index, run) in table.run_refs().iter().enumerate() {
6355                    if run_index % 256 == 0 {
6356                        control.checkpoint()?;
6357                    }
6358                    let source = table.run_path(run.run_id as u64);
6359                    let relative = Path::new(TABLES_DIR)
6360                        .join(table_id.to_string())
6361                        .join(crate::engine::RUNS_DIR)
6362                        .join(format!("r-{}.sr", run.run_id));
6363                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
6364                    if std::fs::hard_link(&source, &pinned).is_err() {
6365                        crate::backup::copy_file_synced(&source, &pinned)?;
6366                    }
6367                    run_files.push(((*table_id, run.run_id), pinned, relative));
6368                }
6369            }
6370            crate::durable_file::sync_directory(&file_pin_root)?;
6371            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
6372            {
6373                let mut pins = self.backup_pins.lock();
6374                for key in &run_keys {
6375                    *pins.entry(*key).or_insert(0) += 1;
6376                }
6377            }
6378            let _run_pins = RunPins {
6379                pins: Arc::clone(&self.backup_pins),
6380                runs: run_keys,
6381            };
6382            // S1C-004: mirror the backup boundary into every table's unified
6383            // pin registry (PinSource::BackupPitr covers PITR base captures,
6384            // which route through this same path). Version GC must not
6385            // reclaim the boundary versions while their runs stream into the
6386            // stage, and diagnostics must expose the pin. The guards drop
6387            // with `_run_pins` when the backup finishes or fails.
6388            let _registry_pins: Vec<crate::retention::PinGuard> = table_guards
6389                .iter()
6390                .map(|table| {
6391                    table
6392                        .pin_registry()
6393                        .pin(crate::retention::PinSource::BackupPitr, Epoch(epoch))
6394                })
6395                .collect();
6396            let deferred: HashSet<_> = run_files
6397                .iter()
6398                .map(|(_, _, relative)| relative.clone())
6399                .collect();
6400            let mut copied = Vec::new();
6401            copy_backup_boundary(
6402                &self.root,
6403                prepared.stage.as_deref().ok_or_else(|| {
6404                    MongrelError::Other("backup staging root was already released".into())
6405                })?,
6406                &deferred,
6407                &mut copied,
6408                Some(control),
6409            )?;
6410
6411            drop(wal);
6412            drop(commit);
6413            drop(table_guards);
6414            drop(security);
6415            drop(ddl);
6416            drop(barrier);
6417
6418            if let Some(hook) = self.backup_hook.lock().as_ref() {
6419                hook();
6420            }
6421            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
6422                if index % 256 == 0 {
6423                    control.checkpoint()?;
6424                }
6425                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
6426                prepared
6427                    .stage
6428                    .as_deref()
6429                    .ok_or_else(|| {
6430                        MongrelError::Other("backup staging root was already released".into())
6431                    })?
6432                    .copy_new_from(&relative, &mut source)?;
6433                copied.push(relative);
6434            }
6435
6436            let manifest = crate::backup::BackupManifest::create_controlled_durable(
6437                prepared.stage.as_deref().ok_or_else(|| {
6438                    MongrelError::Other("backup staging root was already released".into())
6439                })?,
6440                epoch,
6441                &copied,
6442                control,
6443            )?;
6444            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
6445                MongrelError::Other("backup staging root was already released".into())
6446            })?)?;
6447            control.checkpoint()?;
6448            let publish = before_publish.take().ok_or_else(|| {
6449                MongrelError::Other("backup publication callback already consumed".into())
6450            })?;
6451            if !publish() {
6452                return Err(MongrelError::Cancelled);
6453            }
6454            let final_security = self.security_coordinator.gate.read();
6455            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6456            // Windows pins directories without delete sharing. Release the
6457            // stage handle before renaming that directory, while the parent
6458            // remains descriptor-pinned for the no-replace publication.
6459            drop(prepared.stage.take().ok_or_else(|| {
6460                MongrelError::Other("backup staging root was already released".into())
6461            })?);
6462            let published = std::cell::Cell::new(false);
6463            if let Err(error) = prepared.parent.rename_directory_new_with_after(
6464                Path::new(&prepared.stage_name),
6465                &prepared.parent,
6466                Path::new(&prepared.destination_name),
6467                || published.set(true),
6468            ) {
6469                if published.get() {
6470                    return Err(MongrelError::CommitOutcomeUnknown {
6471                        epoch,
6472                        message: format!("backup publication was not durable: {error}"),
6473                    });
6474                }
6475                return Err(error.into());
6476            }
6477            drop(final_security);
6478            Ok(crate::backup::BackupReport {
6479                destination,
6480                epoch,
6481                boundary_unix_nanos,
6482                files: manifest.files.len(),
6483                bytes: manifest.total_bytes(),
6484            })
6485        })();
6486
6487        if outcome.is_err() {
6488            drop(prepared.stage.take());
6489            let _ = prepared
6490                .parent
6491                .remove_directory_all(Path::new(&prepared.stage_name));
6492        }
6493        outcome
6494    }
6495
6496    /// Return complete committed transactions after `since_epoch`. A gap or a
6497    /// transaction backed by a spilled run requires a fresh bootstrap image.
6498    pub fn replication_batch_since(
6499        &self,
6500        since_epoch: u64,
6501    ) -> Result<crate::replication::ReplicationBatch> {
6502        use crate::wal::Op;
6503
6504        let admin = crate::auth::Permission::Admin;
6505        self.require(&admin)?;
6506        // S1A-004: admit the batch extraction as one core operation.
6507        let _operation = self.admit_operation()?;
6508        let operation_principal = self.principal_snapshot();
6509
6510        let mut wal = self.shared_wal.lock();
6511        wal.group_sync()?;
6512        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6513        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
6514        drop(wal);
6515
6516        let commits: HashMap<u64, u64> = records
6517            .iter()
6518            .filter_map(|record| match &record.op {
6519                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
6520                _ => None,
6521            })
6522            .collect();
6523        let earliest_epoch = commits.values().copied().min();
6524        let current_epoch = commits
6525            .values()
6526            .copied()
6527            .max()
6528            .unwrap_or(0)
6529            .max(self.epoch.committed().0);
6530        let selected: HashSet<u64> = commits
6531            .iter()
6532            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
6533            .collect();
6534        let retention_gap = since_epoch < current_epoch
6535            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
6536        let spilled = records.iter().any(|record| {
6537            selected.contains(&record.txn_id)
6538                && matches!(
6539                    &record.op,
6540                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
6541                )
6542        });
6543        let records = records
6544            .into_iter()
6545            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
6546            .filter(|record| selected.contains(&record.txn_id))
6547            .collect();
6548        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
6549        let batch = crate::replication::ReplicationBatch::complete_for_source(
6550            source_id,
6551            since_epoch,
6552            current_epoch,
6553            earliest_epoch,
6554            retention_gap,
6555            spilled,
6556            records,
6557        )?;
6558        if let Some(hook) = self.replication_hook.lock().as_ref() {
6559            hook();
6560        }
6561        let _security = self.security_coordinator.gate.read();
6562        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6563        Ok(batch)
6564    }
6565
6566    /// Durably append a leader batch to a follower's local WAL and checkpoint
6567    /// its catalog metadata. Security changes apply to this live handle before
6568    /// success returns. The caller must reopen to mount new table state.
6569    pub fn append_replication_batch(
6570        &self,
6571        batch: &crate::replication::ReplicationBatch,
6572    ) -> Result<u64> {
6573        use crate::wal::Op;
6574
6575        if !self.read_only {
6576            return Err(MongrelError::InvalidArgument(
6577                "replication batches may only target a marked replica".into(),
6578            ));
6579        }
6580        // S1A-004: admit the follow-apply as one core operation.
6581        let _operation = self.admit_operation()?;
6582        let current = crate::replication::replica_epoch(&self.root)?;
6583        if batch.is_source_bound() {
6584            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
6585            if batch.source_id != source_id {
6586                return Err(MongrelError::Conflict(
6587                    "replication batch source does not match follower binding".into(),
6588                ));
6589            }
6590        }
6591        if batch.requires_snapshot {
6592            return Err(MongrelError::Conflict(
6593                "replication snapshot required for this batch".into(),
6594            ));
6595        }
6596        batch.validate_proof()?;
6597        if batch.from_epoch != current {
6598            if batch.from_epoch < current && batch.current_epoch == current {
6599                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6600                let _wal = self.shared_wal.lock();
6601                let existing: HashSet<(u64, u64)> =
6602                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
6603                        .into_iter()
6604                        .filter_map(|record| match record.op {
6605                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
6606                            _ => None,
6607                        })
6608                        .collect();
6609                let already_applied = batch.records.iter().all(|record| match &record.op {
6610                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
6611                    _ => true,
6612                });
6613                if already_applied {
6614                    return Ok(current);
6615                }
6616            }
6617            return Err(MongrelError::Conflict(format!(
6618                "replication batch starts at epoch {}, follower is at epoch {current}",
6619                batch.from_epoch
6620            )));
6621        }
6622        if batch.current_epoch < current {
6623            return Err(MongrelError::InvalidArgument(format!(
6624                "replication batch current epoch {} precedes follower epoch {current}",
6625                batch.current_epoch
6626            )));
6627        }
6628        let records = &batch.records;
6629        let mut commits = HashMap::new();
6630        let mut commit_epochs = HashSet::new();
6631        let mut commit_timestamps = HashMap::new();
6632        for record in records {
6633            match &record.op {
6634                Op::TxnCommit { epoch, added_runs } => {
6635                    if !added_runs.is_empty() {
6636                        return Err(MongrelError::Conflict(
6637                            "replication snapshot required for spilled-run transaction".into(),
6638                        ));
6639                    }
6640                    if commits.insert(record.txn_id, *epoch).is_some() {
6641                        return Err(MongrelError::InvalidArgument(format!(
6642                            "duplicate commit for replication transaction {}",
6643                            record.txn_id
6644                        )));
6645                    }
6646                    if *epoch <= current || *epoch > batch.current_epoch {
6647                        return Err(MongrelError::InvalidArgument(format!(
6648                            "replication commit epoch {epoch} is outside ({current}, {}]",
6649                            batch.current_epoch
6650                        )));
6651                    }
6652                    if !commit_epochs.insert(*epoch) {
6653                        return Err(MongrelError::InvalidArgument(format!(
6654                            "duplicate replication commit epoch {epoch}"
6655                        )));
6656                    }
6657                }
6658                Op::CommitTimestamp { unix_nanos } => {
6659                    commit_timestamps.insert(record.txn_id, *unix_nanos);
6660                }
6661                _ => {}
6662            }
6663        }
6664        for record in records {
6665            if record.txn_id == crate::wal::SYSTEM_TXN_ID
6666                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
6667            {
6668                return Err(MongrelError::InvalidArgument(
6669                    "replication batch contains a non-committed record".into(),
6670                ));
6671            }
6672            if !commits.contains_key(&record.txn_id) {
6673                return Err(MongrelError::InvalidArgument(format!(
6674                    "incomplete replication transaction {}",
6675                    record.txn_id
6676                )));
6677            }
6678        }
6679        let target_epoch = commits
6680            .values()
6681            .copied()
6682            .filter(|epoch| *epoch > current)
6683            .max()
6684            .unwrap_or(current);
6685        if target_epoch != batch.current_epoch {
6686            return Err(MongrelError::InvalidArgument(format!(
6687                "replication batch ends at epoch {target_epoch}, expected {}",
6688                batch.current_epoch
6689            )));
6690        }
6691        let mut selected: HashSet<u64> = commits
6692            .iter()
6693            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
6694            .collect();
6695        if selected.is_empty() {
6696            return Ok(current);
6697        }
6698        let mut wal = self.shared_wal.lock();
6699        wal.group_sync()?;
6700        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6701        let existing: HashSet<(u64, u64)> =
6702            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
6703                .into_iter()
6704                .filter_map(|record| match record.op {
6705                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
6706                    _ => None,
6707                })
6708                .collect();
6709        selected.retain(|txn_id| {
6710            commits
6711                .get(txn_id)
6712                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
6713        });
6714        for record in records {
6715            if !selected.contains(&record.txn_id) {
6716                continue;
6717            }
6718            match &record.op {
6719                Op::TxnCommit { epoch, added_runs } => {
6720                    let timestamp = commit_timestamps
6721                        .get(&record.txn_id)
6722                        .copied()
6723                        .unwrap_or_else(current_unix_nanos);
6724                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
6725                }
6726                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
6727                op => {
6728                    wal.append(record.txn_id, 0, op.clone())?;
6729                }
6730            }
6731        }
6732        if !selected.is_empty() {
6733            wal.group_sync()?;
6734        }
6735        drop(wal);
6736
6737        // Auth mode is selected before `finish_open` replays the WAL. Make the
6738        // catalog transition durable and publish security state to this live
6739        // handle before reporting success.
6740        let mut recovered_catalog = self.catalog.read().clone();
6741        if let Err(error) = recover_ddl_from_wal(
6742            &self.root,
6743            Some(&self.durable_root),
6744            &mut recovered_catalog,
6745            self.meta_dek.as_ref(),
6746            wal_dek.as_ref(),
6747            true,
6748            None,
6749        ) {
6750            return Err(MongrelError::DurableCommit {
6751                epoch: target_epoch,
6752                message: format!(
6753                    "replication WAL is durable but catalog checkpoint failed: {error}"
6754                ),
6755            });
6756        }
6757        let _security = self.security_coordinator.gate.write();
6758        let old_security_version = self.catalog.read().security_version;
6759        let security_changed = old_security_version != recovered_catalog.security_version
6760            || self.catalog.read().require_auth != recovered_catalog.require_auth;
6761        let require_auth = recovered_catalog.require_auth;
6762        let principal = if security_changed {
6763            None
6764        } else {
6765            self.principal.read().as_ref().and_then(|principal| {
6766                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
6767            })
6768        };
6769        if require_auth {
6770            self.auth_state.set_require_auth(true);
6771        }
6772        self.auth_state.set_principal(principal.clone());
6773        *self.principal.write() = principal;
6774        let security_version = recovered_catalog.security_version;
6775        *self.catalog.write() = recovered_catalog;
6776        self.security_coordinator
6777            .version
6778            .store(security_version, Ordering::Release);
6779        if !require_auth {
6780            self.auth_state.set_require_auth(false);
6781        }
6782        if let Err(error) =
6783            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
6784        {
6785            return Err(MongrelError::DurableCommit {
6786                epoch: target_epoch,
6787                message: format!(
6788                    "replication WAL and catalog are durable but follower watermark failed: {error}"
6789                ),
6790            });
6791        }
6792        Ok(target_epoch)
6793    }
6794
6795    /// Resolve a table name → id (live tables only). pub(crate) so the
6796    /// transaction layer can stage by name.
6797    pub fn table_id(&self, name: &str) -> Result<u64> {
6798        let cat = self.catalog.read();
6799        cat.live(name)
6800            .map(|e| e.table_id)
6801            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
6802    }
6803
6804    /// Return the stable table id and current schema generation from one
6805    /// catalog snapshot. Callers can bind retries to this identity so a table
6806    /// dropped and recreated under the same name is never mistaken for the
6807    /// original resource.
6808    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
6809        let catalog = self.catalog.read();
6810        catalog
6811            .live(name)
6812            .map(|entry| (entry.table_id, entry.schema.schema_id))
6813            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
6814    }
6815
6816    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
6817        self.catalog
6818            .read()
6819            .building(name)
6820            .map(|entry| entry.table_id)
6821            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
6822    }
6823
6824    pub fn procedures(&self) -> Vec<StoredProcedure> {
6825        self.catalog
6826            .read()
6827            .procedures
6828            .iter()
6829            .map(|p| p.procedure.clone())
6830            .collect()
6831    }
6832
6833    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
6834        self.catalog
6835            .read()
6836            .procedures
6837            .iter()
6838            .find(|p| p.procedure.name == name)
6839            .map(|p| p.procedure.clone())
6840    }
6841
6842    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
6843        self.create_procedure_inner(procedure, None)
6844    }
6845
6846    pub fn create_procedure_controlled<F>(
6847        &self,
6848        procedure: StoredProcedure,
6849        mut before_publish: F,
6850    ) -> Result<StoredProcedure>
6851    where
6852        F: FnMut() -> Result<()>,
6853    {
6854        self.create_procedure_inner(procedure, Some(&mut before_publish))
6855    }
6856
6857    fn create_procedure_inner(
6858        &self,
6859        mut procedure: StoredProcedure,
6860        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6861    ) -> Result<StoredProcedure> {
6862        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
6863            procedure: procedure.clone(),
6864        };
6865        self.require(&crate::catalog_cmds::required_permission(&command))?;
6866        let _g = self.ddl_lock.lock();
6867        let _security_write = self.security_write()?;
6868        self.require(&crate::catalog_cmds::required_permission(&command))?;
6869        procedure.validate()?;
6870        self.validate_procedure_references(&procedure)?;
6871        // S1F-001: validation runs pure against the current catalog first so
6872        // failures (duplicate name, unknown table) surface before an epoch is
6873        // consumed, exactly like the legacy pre-bump checks.
6874        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
6875        let commit_lock = Arc::clone(&self.commit_lock);
6876        let _c = commit_lock.lock();
6877        let epoch = self.epoch.bump_assigned();
6878        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6879        procedure.created_epoch = epoch.0;
6880        procedure.updated_epoch = epoch.0;
6881        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
6882            procedure: procedure.clone(),
6883        };
6884        let mut next_catalog = self.catalog.read().clone();
6885        self.apply_catalog_command_to(&mut next_catalog, command)?;
6886        next_catalog.db_epoch = epoch.0;
6887        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6888        Ok(procedure)
6889    }
6890
6891    pub fn create_or_replace_procedure(
6892        &self,
6893        procedure: StoredProcedure,
6894    ) -> Result<StoredProcedure> {
6895        self.create_or_replace_procedure_inner(procedure, None)
6896    }
6897
6898    pub fn create_or_replace_procedure_controlled<F>(
6899        &self,
6900        procedure: StoredProcedure,
6901        mut before_publish: F,
6902    ) -> Result<StoredProcedure>
6903    where
6904        F: FnMut() -> Result<()>,
6905    {
6906        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
6907    }
6908
6909    fn create_or_replace_procedure_inner(
6910        &self,
6911        procedure: StoredProcedure,
6912        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6913    ) -> Result<StoredProcedure> {
6914        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
6915            procedure: procedure.clone(),
6916        };
6917        self.require(&crate::catalog_cmds::required_permission(&command))?;
6918        let _g = self.ddl_lock.lock();
6919        let _security_write = self.security_write()?;
6920        self.require(&crate::catalog_cmds::required_permission(&command))?;
6921        procedure.validate()?;
6922        self.validate_procedure_references(&procedure)?;
6923        // S1F-001: validation runs pure against the current catalog first so
6924        // structural failures surface before an epoch is consumed, exactly
6925        // like the legacy pre-bump checks.
6926        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
6927        let commit_lock = Arc::clone(&self.commit_lock);
6928        let _c = commit_lock.lock();
6929        let epoch = self.epoch.bump_assigned();
6930        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6931        let mut next_catalog = self.catalog.read().clone();
6932        // Resolve the replacement image against the candidate catalog: the
6933        // engine stamps version/epochs from the commit epoch (preserving the
6934        // original created_epoch on replacement), then the command record
6935        // carries that resolved image.
6936        let replaced = match next_catalog
6937            .procedures
6938            .iter()
6939            .position(|p| p.procedure.name == procedure.name)
6940        {
6941            Some(idx) => next_catalog.procedures[idx]
6942                .procedure
6943                .replaced(procedure.clone(), epoch.0)?,
6944            None => {
6945                let mut next = procedure;
6946                next.created_epoch = epoch.0;
6947                next.updated_epoch = epoch.0;
6948                next
6949            }
6950        };
6951        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
6952            procedure: replaced.clone(),
6953        };
6954        self.apply_catalog_command_to(&mut next_catalog, command)?;
6955        next_catalog.db_epoch = epoch.0;
6956        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6957        Ok(replaced)
6958    }
6959
6960    pub fn drop_procedure(&self, name: &str) -> Result<()> {
6961        self.drop_procedure_with_epoch(name).map(|_| ())
6962    }
6963
6964    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
6965        self.drop_procedure_with_epoch_inner(name, None)
6966    }
6967
6968    pub fn drop_procedure_with_epoch_controlled<F>(
6969        &self,
6970        name: &str,
6971        mut before_publish: F,
6972    ) -> Result<Epoch>
6973    where
6974        F: FnMut() -> Result<()>,
6975    {
6976        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
6977    }
6978
6979    fn drop_procedure_with_epoch_inner(
6980        &self,
6981        name: &str,
6982        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6983    ) -> Result<Epoch> {
6984        let command = crate::catalog_cmds::CatalogCommand::DropProcedure {
6985            name: name.to_string(),
6986        };
6987        self.require(&crate::catalog_cmds::required_permission(&command))?;
6988        let _g = self.ddl_lock.lock();
6989        let _security_write = self.security_write()?;
6990        self.require(&crate::catalog_cmds::required_permission(&command))?;
6991        let commit_lock = Arc::clone(&self.commit_lock);
6992        let _c = commit_lock.lock();
6993        let epoch = self.epoch.bump_assigned();
6994        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6995        let mut next_catalog = self.catalog.read().clone();
6996        self.apply_catalog_command_to(&mut next_catalog, command)?;
6997        next_catalog.db_epoch = epoch.0;
6998        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6999        Ok(epoch)
7000    }
7001
7002    // ── User / role / credentials management ─────────────────────────────
7003
7004    /// List all catalog users (password hashes included — callers should not
7005    /// serialize them externally).
7006    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
7007        self.catalog.read().users.clone()
7008    }
7009
7010    /// Resolve only the stable, non-secret identity fields needed to scope
7011    /// request receipts. Password hashes never leave the catalog lock.
7012    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
7013        self.catalog
7014            .read()
7015            .users
7016            .iter()
7017            .find(|user| user.username == username)
7018            .map(|user| (user.id, user.created_epoch))
7019    }
7020
7021    /// Current catalog authorization generation. Retry bindings can include
7022    /// this value to fail closed after roles, grants, or row policies change.
7023    pub fn security_version(&self) -> u64 {
7024        self.catalog.read().security_version
7025    }
7026
7027    /// List all catalog roles.
7028    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
7029        self.catalog.read().roles.clone()
7030    }
7031
7032    /// Create a new user with an Argon2id-hashed password.
7033    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
7034        self.require(&crate::auth::Permission::Admin)?;
7035        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
7036        self.create_user_with_password_hash(username, hash)
7037    }
7038
7039    /// Create a user from a password hash prepared before a commit fence.
7040    pub fn create_user_with_password_hash(
7041        &self,
7042        username: &str,
7043        hash: String,
7044    ) -> Result<crate::auth::UserEntry> {
7045        self.create_user_with_password_hash_inner(username, hash, None)
7046    }
7047
7048    pub fn create_user_with_password_hash_controlled<F>(
7049        &self,
7050        username: &str,
7051        hash: String,
7052        mut before_publish: F,
7053    ) -> Result<crate::auth::UserEntry>
7054    where
7055        F: FnMut() -> Result<()>,
7056    {
7057        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
7058    }
7059
7060    fn create_user_with_password_hash_inner(
7061        &self,
7062        username: &str,
7063        hash: String,
7064        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7065    ) -> Result<crate::auth::UserEntry> {
7066        // S1F-001: the mutation is a versioned catalog command; its required
7067        // permission is checked against the caller principal first (identical
7068        // to the legacy hardcoded Admin gate).
7069        let command = crate::catalog_cmds::CatalogCommand::CreateUser {
7070            username: username.to_string(),
7071            password_hash: hash.clone(),
7072            is_admin: false,
7073            created_epoch: 0, // stamped after the commit epoch is assigned
7074        };
7075        self.require(&crate::catalog_cmds::required_permission(&command))?;
7076        let _ddl = self.ddl_lock.lock();
7077        let _security_write = self.security_write()?;
7078        self.require(&crate::catalog_cmds::required_permission(&command))?;
7079        let _commit = self.commit_lock.lock();
7080        let epoch = self.epoch.bump_assigned();
7081        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7082        let command = crate::catalog_cmds::CatalogCommand::CreateUser {
7083            username: username.to_string(),
7084            password_hash: hash,
7085            is_admin: false,
7086            created_epoch: epoch.0,
7087        };
7088        let mut next_catalog = self.catalog.read().clone();
7089        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
7090        let crate::catalog_cmds::CatalogDelta::UserUpserted(entry) = delta else {
7091            return Err(MongrelError::Other(
7092                "CreateUser resolved to an unexpected catalog delta".into(),
7093            ));
7094        };
7095        next_catalog.db_epoch = epoch.0;
7096        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7097        Ok(entry)
7098    }
7099
7100    /// Drop a user by username.
7101    pub fn drop_user(&self, username: &str) -> Result<()> {
7102        self.drop_user_with_epoch(username).map(|_| ())
7103    }
7104
7105    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
7106        self.drop_user_with_epoch_inner(username, None)
7107    }
7108
7109    pub fn drop_user_with_epoch_controlled<F>(
7110        &self,
7111        username: &str,
7112        mut before_publish: F,
7113    ) -> Result<Epoch>
7114    where
7115        F: FnMut() -> Result<()>,
7116    {
7117        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
7118    }
7119
7120    fn drop_user_with_epoch_inner(
7121        &self,
7122        username: &str,
7123        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7124    ) -> Result<Epoch> {
7125        let command = crate::catalog_cmds::CatalogCommand::DropUser {
7126            username: username.to_string(),
7127        };
7128        self.require(&crate::catalog_cmds::required_permission(&command))?;
7129        let _ddl = self.ddl_lock.lock();
7130        let _security_write = self.security_write()?;
7131        self.require(&crate::catalog_cmds::required_permission(&command))?;
7132        let _commit = self.commit_lock.lock();
7133        let epoch = self.epoch.bump_assigned();
7134        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7135        let mut next_catalog = self.catalog.read().clone();
7136        self.apply_catalog_command_to(&mut next_catalog, command)?;
7137        next_catalog.db_epoch = epoch.0;
7138        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7139        Ok(epoch)
7140    }
7141
7142    /// Change a user's password.
7143    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
7144        self.alter_user_password_with_epoch(username, new_password)
7145            .map(|_| ())
7146    }
7147
7148    pub fn alter_user_password_with_epoch(
7149        &self,
7150        username: &str,
7151        new_password: &str,
7152    ) -> Result<Epoch> {
7153        self.require(&crate::auth::Permission::Admin)?;
7154        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
7155        self.alter_user_password_hash_with_epoch(username, hash)
7156    }
7157
7158    pub fn alter_user_password_hash_with_epoch(
7159        &self,
7160        username: &str,
7161        hash: String,
7162    ) -> Result<Epoch> {
7163        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
7164    }
7165
7166    pub fn alter_user_password_hash_with_epoch_controlled<F>(
7167        &self,
7168        username: &str,
7169        hash: String,
7170        mut before_publish: F,
7171    ) -> Result<Epoch>
7172    where
7173        F: FnMut() -> Result<()>,
7174    {
7175        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
7176    }
7177
7178    fn alter_user_password_hash_with_epoch_inner(
7179        &self,
7180        username: &str,
7181        hash: String,
7182        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7183    ) -> Result<Epoch> {
7184        let command = crate::catalog_cmds::CatalogCommand::AlterUserPassword {
7185            username: username.to_string(),
7186            password_hash: hash,
7187        };
7188        self.require(&crate::catalog_cmds::required_permission(&command))?;
7189        let _ddl = self.ddl_lock.lock();
7190        let _security_write = self.security_write()?;
7191        self.require(&crate::catalog_cmds::required_permission(&command))?;
7192        let _commit = self.commit_lock.lock();
7193        let epoch = self.epoch.bump_assigned();
7194        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7195        let mut next_catalog = self.catalog.read().clone();
7196        self.apply_catalog_command_to(&mut next_catalog, command)?;
7197        next_catalog.db_epoch = epoch.0;
7198        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7199        Ok(epoch)
7200    }
7201
7202    /// Verify credentials. Returns `Some(entry)` on success, `None` on
7203    /// mismatch, `Err` on engine error.
7204    pub fn verify_user(
7205        &self,
7206        username: &str,
7207        password: &str,
7208    ) -> Result<Option<crate::auth::UserEntry>> {
7209        let cat = self.catalog.read();
7210        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
7211            return Ok(None);
7212        };
7213        if user.password_hash.is_empty() {
7214            return Ok(None);
7215        }
7216        let ok = crate::auth::verify_password(password, &user.password_hash)
7217            .map_err(MongrelError::Other)?;
7218        if ok {
7219            Ok(Some(user.clone()))
7220        } else {
7221            Ok(None)
7222        }
7223    }
7224
7225    /// Authenticate and resolve one immutable principal from the same catalog
7226    /// snapshot. Username reuse cannot bridge the password check and principal
7227    /// resolution.
7228    pub fn authenticate_principal(
7229        &self,
7230        username: &str,
7231        password: &str,
7232    ) -> Result<Option<crate::auth::Principal>> {
7233        self.authenticate_principal_inner(username, password, || {})
7234    }
7235
7236    fn authenticate_principal_inner<F>(
7237        &self,
7238        username: &str,
7239        password: &str,
7240        after_verify: F,
7241    ) -> Result<Option<crate::auth::Principal>>
7242    where
7243        F: FnOnce(),
7244    {
7245        let catalog = self.catalog.read();
7246        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
7247            return Ok(None);
7248        };
7249        if user.password_hash.is_empty()
7250            || !crate::auth::verify_password(password, &user.password_hash)
7251                .map_err(MongrelError::Other)?
7252        {
7253            return Ok(None);
7254        }
7255        after_verify();
7256        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
7257    }
7258
7259    /// Grant admin privileges to a user (bypasses all permission checks).
7260    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
7261        self.set_user_admin_with_epoch(username, is_admin)
7262            .map(|_| ())
7263    }
7264
7265    pub fn set_user_admin_with_epoch(
7266        &self,
7267        username: &str,
7268        is_admin: bool,
7269    ) -> Result<Option<Epoch>> {
7270        self.set_user_admin_with_epoch_inner(username, is_admin, None)
7271    }
7272
7273    pub fn set_user_admin_with_epoch_controlled<F>(
7274        &self,
7275        username: &str,
7276        is_admin: bool,
7277        mut before_publish: F,
7278    ) -> Result<Option<Epoch>>
7279    where
7280        F: FnMut() -> Result<()>,
7281    {
7282        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
7283    }
7284
7285    fn set_user_admin_with_epoch_inner(
7286        &self,
7287        username: &str,
7288        is_admin: bool,
7289        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7290    ) -> Result<Option<Epoch>> {
7291        let command = crate::catalog_cmds::CatalogCommand::SetUserAdmin {
7292            username: username.to_string(),
7293            is_admin,
7294        };
7295        self.require(&crate::catalog_cmds::required_permission(&command))?;
7296        let _ddl = self.ddl_lock.lock();
7297        let _security_write = self.security_write()?;
7298        self.require(&crate::catalog_cmds::required_permission(&command))?;
7299        let _commit = self.commit_lock.lock();
7300        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7301        // short-circuit), so validation runs pure first and only real changes
7302        // are applied and recorded.
7303        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7304        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7305            return Ok(None);
7306        }
7307        let epoch = self.epoch.bump_assigned();
7308        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7309        let mut next_catalog = self.catalog.read().clone();
7310        self.apply_catalog_command_to(&mut next_catalog, command)?;
7311        next_catalog.db_epoch = epoch.0;
7312        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7313        Ok(Some(epoch))
7314    }
7315
7316    /// Create a new role.
7317    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
7318        self.create_role_inner(name, None)
7319    }
7320
7321    pub fn create_role_controlled<F>(
7322        &self,
7323        name: &str,
7324        mut before_publish: F,
7325    ) -> Result<crate::auth::RoleEntry>
7326    where
7327        F: FnMut() -> Result<()>,
7328    {
7329        self.create_role_inner(name, Some(&mut before_publish))
7330    }
7331
7332    fn create_role_inner(
7333        &self,
7334        name: &str,
7335        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7336    ) -> Result<crate::auth::RoleEntry> {
7337        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
7338            name: name.to_string(),
7339            created_epoch: 0, // stamped after the commit epoch is assigned
7340        };
7341        self.require(&crate::catalog_cmds::required_permission(&command))?;
7342        let _ddl = self.ddl_lock.lock();
7343        let _security_write = self.security_write()?;
7344        self.require(&crate::catalog_cmds::required_permission(&command))?;
7345        let _commit = self.commit_lock.lock();
7346        let epoch = self.epoch.bump_assigned();
7347        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7348        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
7349            name: name.to_string(),
7350            created_epoch: epoch.0,
7351        };
7352        let mut next_catalog = self.catalog.read().clone();
7353        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
7354        let crate::catalog_cmds::CatalogDelta::RoleUpserted(entry) = delta else {
7355            return Err(MongrelError::Other(
7356                "CreateRole resolved to an unexpected catalog delta".into(),
7357            ));
7358        };
7359        next_catalog.db_epoch = epoch.0;
7360        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7361        Ok(entry)
7362    }
7363
7364    /// Drop a role by name.
7365    pub fn drop_role(&self, name: &str) -> Result<()> {
7366        self.drop_role_with_epoch(name).map(|_| ())
7367    }
7368
7369    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
7370        self.drop_role_with_epoch_inner(name, None)
7371    }
7372
7373    pub fn drop_role_with_epoch_controlled<F>(
7374        &self,
7375        name: &str,
7376        mut before_publish: F,
7377    ) -> Result<Epoch>
7378    where
7379        F: FnMut() -> Result<()>,
7380    {
7381        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
7382    }
7383
7384    fn drop_role_with_epoch_inner(
7385        &self,
7386        name: &str,
7387        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7388    ) -> Result<Epoch> {
7389        let command = crate::catalog_cmds::CatalogCommand::DropRole {
7390            name: name.to_string(),
7391        };
7392        self.require(&crate::catalog_cmds::required_permission(&command))?;
7393        let _ddl = self.ddl_lock.lock();
7394        let _security_write = self.security_write()?;
7395        self.require(&crate::catalog_cmds::required_permission(&command))?;
7396        let _commit = self.commit_lock.lock();
7397        let epoch = self.epoch.bump_assigned();
7398        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7399        let mut next_catalog = self.catalog.read().clone();
7400        self.apply_catalog_command_to(&mut next_catalog, command)?;
7401        next_catalog.db_epoch = epoch.0;
7402        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7403        Ok(epoch)
7404    }
7405
7406    /// Grant a role to a user.
7407    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
7408        self.grant_role_with_epoch(username, role_name).map(|_| ())
7409    }
7410
7411    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
7412        self.grant_role_with_epoch_inner(username, role_name, None)
7413    }
7414
7415    pub fn grant_role_with_epoch_controlled<F>(
7416        &self,
7417        username: &str,
7418        role_name: &str,
7419        mut before_publish: F,
7420    ) -> Result<Option<Epoch>>
7421    where
7422        F: FnMut() -> Result<()>,
7423    {
7424        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
7425    }
7426
7427    fn grant_role_with_epoch_inner(
7428        &self,
7429        username: &str,
7430        role_name: &str,
7431        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7432    ) -> Result<Option<Epoch>> {
7433        let command = crate::catalog_cmds::CatalogCommand::GrantRole {
7434            username: username.to_string(),
7435            role: role_name.to_string(),
7436        };
7437        self.require(&crate::catalog_cmds::required_permission(&command))?;
7438        let _ddl = self.ddl_lock.lock();
7439        let _security_write = self.security_write()?;
7440        self.require(&crate::catalog_cmds::required_permission(&command))?;
7441        let _commit = self.commit_lock.lock();
7442        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7443        // short-circuit), so validation runs pure first and only real changes
7444        // are applied and recorded.
7445        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7446        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7447            return Ok(None);
7448        }
7449        let epoch = self.epoch.bump_assigned();
7450        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7451        let mut next_catalog = self.catalog.read().clone();
7452        self.apply_catalog_command_to(&mut next_catalog, command)?;
7453        next_catalog.db_epoch = epoch.0;
7454        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7455        Ok(Some(epoch))
7456    }
7457
7458    /// Revoke a role from a user.
7459    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
7460        self.revoke_role_with_epoch(username, role_name).map(|_| ())
7461    }
7462
7463    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
7464        self.revoke_role_with_epoch_inner(username, role_name, None)
7465    }
7466
7467    pub fn revoke_role_with_epoch_controlled<F>(
7468        &self,
7469        username: &str,
7470        role_name: &str,
7471        mut before_publish: F,
7472    ) -> Result<Option<Epoch>>
7473    where
7474        F: FnMut() -> Result<()>,
7475    {
7476        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
7477    }
7478
7479    fn revoke_role_with_epoch_inner(
7480        &self,
7481        username: &str,
7482        role_name: &str,
7483        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7484    ) -> Result<Option<Epoch>> {
7485        let command = crate::catalog_cmds::CatalogCommand::RevokeRole {
7486            username: username.to_string(),
7487            role: role_name.to_string(),
7488        };
7489        self.require(&crate::catalog_cmds::required_permission(&command))?;
7490        let _ddl = self.ddl_lock.lock();
7491        let _security_write = self.security_write()?;
7492        self.require(&crate::catalog_cmds::required_permission(&command))?;
7493        let _commit = self.commit_lock.lock();
7494        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7495        // short-circuit), so validation runs pure first and only real changes
7496        // are applied and recorded.
7497        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7498        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7499            return Ok(None);
7500        }
7501        let epoch = self.epoch.bump_assigned();
7502        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7503        let mut next_catalog = self.catalog.read().clone();
7504        self.apply_catalog_command_to(&mut next_catalog, command)?;
7505        next_catalog.db_epoch = epoch.0;
7506        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7507        Ok(Some(epoch))
7508    }
7509
7510    /// Grant a permission to a role.
7511    pub fn grant_permission(
7512        &self,
7513        role_name: &str,
7514        permission: crate::auth::Permission,
7515    ) -> Result<()> {
7516        self.grant_permission_with_epoch(role_name, permission)
7517            .map(|_| ())
7518    }
7519
7520    pub fn grant_permission_with_epoch(
7521        &self,
7522        role_name: &str,
7523        permission: crate::auth::Permission,
7524    ) -> Result<Option<Epoch>> {
7525        self.grant_permission_with_epoch_inner(role_name, permission, None)
7526    }
7527
7528    pub fn grant_permission_with_epoch_controlled<F>(
7529        &self,
7530        role_name: &str,
7531        permission: crate::auth::Permission,
7532        mut before_publish: F,
7533    ) -> Result<Option<Epoch>>
7534    where
7535        F: FnMut() -> Result<()>,
7536    {
7537        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
7538    }
7539
7540    fn grant_permission_with_epoch_inner(
7541        &self,
7542        role_name: &str,
7543        permission: crate::auth::Permission,
7544        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7545    ) -> Result<Option<Epoch>> {
7546        let command = crate::catalog_cmds::CatalogCommand::GrantPermission {
7547            role: role_name.to_string(),
7548            permission,
7549        };
7550        self.require(&crate::catalog_cmds::required_permission(&command))?;
7551        let _ddl = self.ddl_lock.lock();
7552        let _security_write = self.security_write()?;
7553        self.require(&crate::catalog_cmds::required_permission(&command))?;
7554        let _commit = self.commit_lock.lock();
7555        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7556        // short-circuit), so validation runs pure first and only real changes
7557        // are applied and recorded.
7558        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7559        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7560            return Ok(None);
7561        }
7562        let epoch = self.epoch.bump_assigned();
7563        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7564        let mut next_catalog = self.catalog.read().clone();
7565        self.apply_catalog_command_to(&mut next_catalog, command)?;
7566        next_catalog.db_epoch = epoch.0;
7567        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7568        Ok(Some(epoch))
7569    }
7570
7571    /// Revoke a permission from a role.
7572    pub fn revoke_permission(
7573        &self,
7574        role_name: &str,
7575        permission: crate::auth::Permission,
7576    ) -> Result<()> {
7577        self.revoke_permission_with_epoch(role_name, permission)
7578            .map(|_| ())
7579    }
7580
7581    pub fn revoke_permission_with_epoch(
7582        &self,
7583        role_name: &str,
7584        permission: crate::auth::Permission,
7585    ) -> Result<Option<Epoch>> {
7586        self.revoke_permission_with_epoch_inner(role_name, permission, None)
7587    }
7588
7589    pub fn revoke_permission_with_epoch_controlled<F>(
7590        &self,
7591        role_name: &str,
7592        permission: crate::auth::Permission,
7593        mut before_publish: F,
7594    ) -> Result<Option<Epoch>>
7595    where
7596        F: FnMut() -> Result<()>,
7597    {
7598        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
7599    }
7600
7601    fn revoke_permission_with_epoch_inner(
7602        &self,
7603        role_name: &str,
7604        permission: crate::auth::Permission,
7605        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7606    ) -> Result<Option<Epoch>> {
7607        let command = crate::catalog_cmds::CatalogCommand::RevokePermission {
7608            role: role_name.to_string(),
7609            permission,
7610        };
7611        self.require(&crate::catalog_cmds::required_permission(&command))?;
7612        let _ddl = self.ddl_lock.lock();
7613        let _security_write = self.security_write()?;
7614        self.require(&crate::catalog_cmds::required_permission(&command))?;
7615        let _commit = self.commit_lock.lock();
7616        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7617        // short-circuit), so validation runs pure first and only real changes
7618        // are applied and recorded.
7619        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7620        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7621            return Ok(None);
7622        }
7623        let epoch = self.epoch.bump_assigned();
7624        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7625        let mut next_catalog = self.catalog.read().clone();
7626        self.apply_catalog_command_to(&mut next_catalog, command)?;
7627        next_catalog.db_epoch = epoch.0;
7628        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7629        Ok(Some(epoch))
7630    }
7631
7632    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
7633    /// permissions from their roles. Returns `None` if the user doesn't exist.
7634    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
7635        let cat = self.catalog.read();
7636        Self::resolve_principal_from_catalog(&cat, username)
7637    }
7638
7639    /// Re-resolve only when the immutable user identity still exists. This is
7640    /// the server/session validation path; username reuse never matches.
7641    pub fn resolve_current_principal(
7642        &self,
7643        principal: &crate::auth::Principal,
7644    ) -> Option<crate::auth::Principal> {
7645        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
7646    }
7647
7648    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
7649    /// without needing a constructed `Database`. Used by the credentialed open
7650    /// path (which must verify credentials before the `Database` exists) and
7651    /// by [`resolve_principal`](Self::resolve_principal).
7652    fn resolve_principal_from_catalog(
7653        cat: &Catalog,
7654        username: &str,
7655    ) -> Option<crate::auth::Principal> {
7656        let user = cat.users.iter().find(|u| u.username == username)?;
7657        Self::resolve_user_principal_from_catalog(cat, user)
7658    }
7659
7660    fn resolve_bound_principal_from_catalog(
7661        cat: &Catalog,
7662        principal: &crate::auth::Principal,
7663    ) -> Option<crate::auth::Principal> {
7664        let user = cat.users.iter().find(|user| {
7665            user.id == principal.user_id
7666                && user.created_epoch == principal.created_epoch
7667                && user.username == principal.username
7668        })?;
7669        Self::resolve_user_principal_from_catalog(cat, user)
7670    }
7671
7672    fn resolve_user_principal_from_catalog(
7673        cat: &Catalog,
7674        user: &crate::auth::UserEntry,
7675    ) -> Option<crate::auth::Principal> {
7676        let mut permissions = Vec::new();
7677        for role_name in &user.roles {
7678            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
7679                permissions.extend(role.permissions.iter().cloned());
7680            }
7681        }
7682        Some(crate::auth::Principal {
7683            user_id: user.id,
7684            created_epoch: user.created_epoch,
7685            username: user.username.clone(),
7686            is_admin: user.is_admin,
7687            roles: user.roles.clone(),
7688            permissions,
7689        })
7690    }
7691
7692    /// Check whether a user has a specific permission (via their roles).
7693    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
7694        match self.resolve_principal(username) {
7695            Some(p) => p.has_permission(permission),
7696            None => false,
7697        }
7698    }
7699
7700    /// Returns `true` if this database's catalog has `require_auth = true`.
7701    /// When true, every operation consults the cached [`Principal`] via
7702    /// [`require`](Self::require).
7703    pub fn require_auth_enabled(&self) -> bool {
7704        self.catalog.read().require_auth
7705    }
7706
7707    /// A snapshot of the cached principal for this handle, if any. `None` for
7708    /// databases opened without credentials (the default). Returns a clone
7709    /// because the principal lives behind an `RwLock`.
7710    pub fn principal(&self) -> Option<crate::auth::Principal> {
7711        self.principal.read().clone()
7712    }
7713
7714    /// Build a `TableAuthChecker` from the current auth state. Used when
7715    /// mounting a new table (`create_table`) so the table inherits the
7716    /// database's enforcement configuration. The checker reads the live
7717    /// `require_auth` flag and cached principal, so changes via `enable_auth`
7718    /// / `refresh_principal` propagate to already-mounted tables.
7719    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
7720        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
7721            self.auth_state.clone(),
7722        )))
7723    }
7724
7725    /// Re-resolve the cached principal from the shared current catalog.
7726    /// Long-lived
7727    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
7728    /// possibly made by a different handle to the same database — to pick up
7729    /// the new effective permissions without re-verifying the password.
7730    ///
7731    /// The process-wide security version reloads from disk only when another
7732    /// handle published a newer catalog. The username is taken from
7733    /// the existing cached principal; if the user has since been dropped,
7734    /// returns [`MongrelError::InvalidCredentials`].
7735    ///
7736    /// No-op (returns `Ok(())`) on a credentialless database, or on a
7737    /// credentialed database whose cached principal is `None`.
7738    pub fn refresh_principal(&self) -> Result<()> {
7739        let previous = match self.principal.read().clone() {
7740            Some(principal) => principal,
7741            None => return Ok(()),
7742        };
7743        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
7744        self.refresh_security_catalog_if_stale(observed_version)?;
7745        let cat = self.catalog.read();
7746        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
7747            Some(p) => {
7748                *self.principal.write() = Some(p.clone());
7749                // Update the shared auth state so mounted Tables see the new
7750                // permissions immediately (Tables read from AuthState, not from
7751                // self.principal).
7752                self.auth_state.set_principal(Some(p));
7753                Ok(())
7754            }
7755            None => Err(MongrelError::InvalidCredentials {
7756                username: previous.username,
7757            }),
7758        }
7759    }
7760
7761    /// Number of security-catalog disk reloads performed by this open handle.
7762    /// Initial open reads are excluded.
7763    pub fn security_catalog_disk_read_count(&self) -> u64 {
7764        self.security_catalog_disk_reads.load(Ordering::Relaxed)
7765    }
7766
7767    /// Convert a credentialless database to a credentialed one: create the
7768    /// first admin user, set `require_auth = true`, and cache the admin
7769    /// principal on this handle so subsequent operations on the same handle
7770    /// continue to work. After this call, the database can only be reopened
7771    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
7772    ///
7773    /// Refuses if the database already has `require_auth = true`. This is
7774    /// the conversion path for existing databases; for fresh databases,
7775    /// `create_with_credentials` sets everything up atomically.
7776    ///
7777    /// See `docs/15-credential-enforcement.md`.
7778    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
7779        if self.shared {
7780            // Fail closed (spec §4.6): one shared handle must not flip the
7781            // core into an enforcement mode the other handles cannot observe.
7782            // Stage 1A shared cores stay credentialless; auth-mode transitions
7783            // require an exclusive `Database` owner. Per-handle principals
7784            // land with Stage 1D sessions.
7785            return Err(MongrelError::Conflict(
7786                "enable_auth requires an exclusive Database owner; shared cores reject \
7787                 auth-mode transitions"
7788                    .into(),
7789            ));
7790        }
7791        let password_hash =
7792            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
7793        let _ddl = self.ddl_lock.lock();
7794        let _security_write = self.security_write()?;
7795        let _commit = self.commit_lock.lock();
7796        let epoch = self.epoch.bump_assigned();
7797        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7798        let mut next_catalog = self.catalog.read().clone();
7799        if next_catalog.require_auth {
7800            return Err(MongrelError::InvalidArgument(
7801                "database already has require_auth enabled".into(),
7802            ));
7803        }
7804        if next_catalog
7805            .users
7806            .iter()
7807            .any(|u| u.username == admin_username)
7808        {
7809            return Err(MongrelError::InvalidArgument(format!(
7810                "user {admin_username:?} already exists"
7811            )));
7812        }
7813        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
7814        let id = next_catalog.next_user_id;
7815        next_catalog.next_user_id = id
7816            .checked_add(1)
7817            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
7818        next_catalog.users.push(crate::auth::UserEntry {
7819            id,
7820            username: admin_username.to_string(),
7821            password_hash,
7822            roles: Vec::new(),
7823            is_admin: true,
7824            created_epoch: epoch.0,
7825        });
7826        next_catalog.require_auth = true;
7827        advance_security_version(&mut next_catalog)?;
7828        next_catalog.db_epoch = epoch.0;
7829        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
7830        // Cache the admin principal on this handle + update the shared auth
7831        // state whenever rename published, even if directory fsync was
7832        // inconclusive.
7833        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
7834            let principal = crate::auth::Principal {
7835                user_id: id,
7836                created_epoch: epoch.0,
7837                username: admin_username.to_string(),
7838                is_admin: true,
7839                roles: Vec::new(),
7840                permissions: Vec::new(),
7841            };
7842            *self.principal.write() = Some(principal.clone());
7843            self.auth_state.set_principal(Some(principal));
7844        }
7845        publish
7846    }
7847
7848    /// Disable `require_auth` on this database, reverting it to credentialless
7849    /// mode. This is the **recovery** path — it requires the handle to already
7850    /// be open (and therefore already authenticated if `require_auth` was on).
7851    ///
7852    /// After this call, the database can be reopened with plain
7853    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
7854    /// credentials. All existing users and roles are preserved in the catalog
7855    /// (so `require_auth` can be re-enabled without recreating them), but they
7856    /// are no longer consulted for enforcement.
7857    ///
7858    /// For true **offline** recovery (when credentials are lost and no
7859    /// authenticated handle is available), the caller opens the database
7860    /// directly via the catalog file (filesystem access required) and calls
7861    /// this method — see the CLI's `auth disable-offline` command.
7862    ///
7863    /// See `docs/15-credential-enforcement.md` §4.7.
7864    pub fn disable_auth(&self) -> Result<()> {
7865        let _ddl = self.ddl_lock.lock();
7866        let _security_write = self.security_write()?;
7867        let _commit = self.commit_lock.lock();
7868        let epoch = self.epoch.bump_assigned();
7869        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7870        let mut next_catalog = self.catalog.read().clone();
7871        if !next_catalog.require_auth {
7872            return Err(MongrelError::InvalidArgument(
7873                "database does not have require_auth enabled".into(),
7874            ));
7875        }
7876        next_catalog.require_auth = false;
7877        advance_security_version(&mut next_catalog)?;
7878        next_catalog.db_epoch = epoch.0;
7879        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
7880        // Clear the cached principal — enforcement is now off.
7881        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
7882            *self.principal.write() = None;
7883        }
7884        publish
7885    }
7886
7887    /// Enforcement check: if the catalog has `require_auth = true`, verify
7888    /// that the cached principal satisfies `perm`. Called by every
7889    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
7890    /// Table/Transaction/MongrelSession operations).
7891    ///
7892    /// On a credentialless database this is a no-op (`Ok(())`).
7893    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
7894        self.ensure_owner_process()?;
7895        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
7896            return Err(MongrelError::ReadOnlyReplica);
7897        }
7898        if self.principal.read().is_some() {
7899            self.refresh_principal().map_err(|error| match error {
7900                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
7901                error => error,
7902            })?;
7903        }
7904        if !self.catalog.read().require_auth {
7905            return Ok(());
7906        }
7907        let guard = self.principal.read();
7908        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
7909        if p.has_permission(perm) {
7910            Ok(())
7911        } else {
7912            Err(MongrelError::PermissionDenied {
7913                required: perm.clone(),
7914                principal: p.username.clone(),
7915            })
7916        }
7917    }
7918
7919    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
7920    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
7921    /// other callers that know the operation kind + table name but don't want
7922    /// to construct the full `Permission` enum value themselves.
7923    pub fn require_table(
7924        &self,
7925        table: &str,
7926        perm: crate::auth_state::RequiredPermission,
7927    ) -> Result<()> {
7928        self.require(&perm.into_permission(table))
7929    }
7930
7931    pub fn triggers(&self) -> Vec<StoredTrigger> {
7932        self.catalog
7933            .read()
7934            .triggers
7935            .iter()
7936            .map(|t| t.trigger.clone())
7937            .collect()
7938    }
7939
7940    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
7941        self.catalog
7942            .read()
7943            .triggers
7944            .iter()
7945            .find(|t| t.trigger.name == name)
7946            .map(|t| t.trigger.clone())
7947    }
7948
7949    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
7950        self.create_trigger_inner(trigger, None, None)
7951    }
7952
7953    pub fn create_trigger_controlled<F>(
7954        &self,
7955        trigger: StoredTrigger,
7956        mut before_publish: F,
7957    ) -> Result<StoredTrigger>
7958    where
7959        F: FnMut() -> Result<()>,
7960    {
7961        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
7962    }
7963
7964    pub fn create_trigger_as_controlled<F>(
7965        &self,
7966        trigger: StoredTrigger,
7967        principal: Option<&crate::auth::Principal>,
7968        mut before_publish: F,
7969    ) -> Result<StoredTrigger>
7970    where
7971        F: FnMut() -> Result<()>,
7972    {
7973        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
7974    }
7975
7976    fn create_trigger_inner(
7977        &self,
7978        mut trigger: StoredTrigger,
7979        principal: Option<&crate::auth::Principal>,
7980        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7981    ) -> Result<StoredTrigger> {
7982        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
7983            trigger: trigger.clone(),
7984        };
7985        self.require_for(
7986            principal,
7987            &crate::catalog_cmds::required_permission(&command),
7988        )?;
7989        let _g = self.ddl_lock.lock();
7990        let _security_write = self.security_write()?;
7991        self.require_for(
7992            principal,
7993            &crate::catalog_cmds::required_permission(&command),
7994        )?;
7995        trigger.validate()?;
7996        self.validate_trigger_references(&trigger)
7997            .map_err(trigger_validation_error)?;
7998        // S1F-001: validation runs pure against the current catalog first so
7999        // failures (duplicate name, unknown target) surface before an epoch is
8000        // consumed, exactly like the legacy pre-bump checks.
8001        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8002        let commit_lock = Arc::clone(&self.commit_lock);
8003        let _c = commit_lock.lock();
8004        let epoch = self.epoch.bump_assigned();
8005        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8006        trigger.created_epoch = epoch.0;
8007        trigger.updated_epoch = epoch.0;
8008        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
8009            trigger: trigger.clone(),
8010        };
8011        let mut next_catalog = self.catalog.read().clone();
8012        self.apply_catalog_command_to(&mut next_catalog, command)?;
8013        next_catalog.db_epoch = epoch.0;
8014        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8015        Ok(trigger)
8016    }
8017
8018    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
8019        self.create_or_replace_trigger_inner(trigger, None, None)
8020    }
8021
8022    pub fn create_or_replace_trigger_controlled<F>(
8023        &self,
8024        trigger: StoredTrigger,
8025        mut before_publish: F,
8026    ) -> Result<StoredTrigger>
8027    where
8028        F: FnMut() -> Result<()>,
8029    {
8030        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
8031    }
8032
8033    pub fn create_or_replace_trigger_as_controlled<F>(
8034        &self,
8035        trigger: StoredTrigger,
8036        principal: Option<&crate::auth::Principal>,
8037        mut before_publish: F,
8038    ) -> Result<StoredTrigger>
8039    where
8040        F: FnMut() -> Result<()>,
8041    {
8042        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
8043    }
8044
8045    fn create_or_replace_trigger_inner(
8046        &self,
8047        trigger: StoredTrigger,
8048        principal: Option<&crate::auth::Principal>,
8049        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8050    ) -> Result<StoredTrigger> {
8051        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
8052            trigger: trigger.clone(),
8053        };
8054        self.require_for(
8055            principal,
8056            &crate::catalog_cmds::required_permission(&command),
8057        )?;
8058        let _g = self.ddl_lock.lock();
8059        let _security_write = self.security_write()?;
8060        self.require_for(
8061            principal,
8062            &crate::catalog_cmds::required_permission(&command),
8063        )?;
8064        trigger.validate()?;
8065        self.validate_trigger_references(&trigger)
8066            .map_err(trigger_validation_error)?;
8067        // S1F-001: validation runs pure against the current catalog first so
8068        // structural failures surface before an epoch is consumed, exactly
8069        // like the legacy pre-bump checks.
8070        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8071        let commit_lock = Arc::clone(&self.commit_lock);
8072        let _c = commit_lock.lock();
8073        let epoch = self.epoch.bump_assigned();
8074        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8075        let mut next_catalog = self.catalog.read().clone();
8076        // Resolve the replacement image against the candidate catalog: the
8077        // engine stamps version/epochs from the commit epoch (preserving the
8078        // original created_epoch on replacement), then the command record
8079        // carries that resolved image.
8080        let replaced = match next_catalog
8081            .triggers
8082            .iter()
8083            .position(|t| t.trigger.name == trigger.name)
8084        {
8085            Some(idx) => next_catalog.triggers[idx]
8086                .trigger
8087                .replaced(trigger.clone(), epoch.0)?,
8088            None => {
8089                let mut next = trigger;
8090                next.created_epoch = epoch.0;
8091                next.updated_epoch = epoch.0;
8092                next
8093            }
8094        };
8095        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
8096            trigger: replaced.clone(),
8097        };
8098        self.apply_catalog_command_to(&mut next_catalog, command)?;
8099        next_catalog.db_epoch = epoch.0;
8100        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8101        Ok(replaced)
8102    }
8103
8104    pub fn drop_trigger(&self, name: &str) -> Result<()> {
8105        self.drop_trigger_with_epoch(name).map(|_| ())
8106    }
8107
8108    /// Drop one trigger and return the exact catalog publication epoch.
8109    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
8110        self.drop_triggers_with_epoch(&[name.to_string()])
8111    }
8112
8113    pub fn drop_trigger_with_epoch_controlled<F>(
8114        &self,
8115        name: &str,
8116        before_publish: F,
8117    ) -> Result<Epoch>
8118    where
8119        F: FnMut() -> Result<()>,
8120    {
8121        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
8122    }
8123
8124    /// Atomically drop several triggers in one catalog publication.
8125    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
8126        self.drop_triggers_with_epoch_inner(names, None, None)
8127    }
8128
8129    pub fn drop_triggers_with_epoch_controlled<F>(
8130        &self,
8131        names: &[String],
8132        mut before_publish: F,
8133    ) -> Result<Epoch>
8134    where
8135        F: FnMut() -> Result<()>,
8136    {
8137        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
8138    }
8139
8140    pub fn drop_triggers_with_epoch_as_controlled<F>(
8141        &self,
8142        names: &[String],
8143        principal: Option<&crate::auth::Principal>,
8144        mut before_publish: F,
8145    ) -> Result<Epoch>
8146    where
8147        F: FnMut() -> Result<()>,
8148    {
8149        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
8150    }
8151
8152    fn drop_triggers_with_epoch_inner(
8153        &self,
8154        names: &[String],
8155        principal: Option<&crate::auth::Principal>,
8156        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8157    ) -> Result<Epoch> {
8158        if names.is_empty() {
8159            return Err(MongrelError::InvalidArgument(
8160                "at least one trigger name is required".into(),
8161            ));
8162        }
8163        let commands: Vec<crate::catalog_cmds::CatalogCommand> = names
8164            .iter()
8165            .map(|name| crate::catalog_cmds::CatalogCommand::DropTrigger { name: name.clone() })
8166            .collect();
8167        for command in &commands {
8168            self.require_for(
8169                principal,
8170                &crate::catalog_cmds::required_permission(command),
8171            )?;
8172        }
8173        let _g = self.ddl_lock.lock();
8174        let _security_write = self.security_write()?;
8175        for command in &commands {
8176            self.require_for(
8177                principal,
8178                &crate::catalog_cmds::required_permission(command),
8179            )?;
8180        }
8181        // S1F-001: every name's command validates pure against the current
8182        // catalog first so a missing trigger surfaces before an epoch is
8183        // consumed, exactly like the legacy pre-bump check. A batch drop then
8184        // records one command per name (the emitting-layer expansion rule).
8185        {
8186            let cat = self.catalog.read();
8187            for command in &commands {
8188                crate::catalog_cmds::apply(&cat, command)?;
8189            }
8190        }
8191        let commit_lock = Arc::clone(&self.commit_lock);
8192        let _c = commit_lock.lock();
8193        let epoch = self.epoch.bump_assigned();
8194        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8195        let mut next_catalog = self.catalog.read().clone();
8196        for command in commands {
8197            self.apply_catalog_command_to(&mut next_catalog, command)?;
8198        }
8199        next_catalog.db_epoch = epoch.0;
8200        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8201        Ok(epoch)
8202    }
8203
8204    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
8205        self.catalog.read().external_tables.clone()
8206    }
8207
8208    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
8209        self.catalog
8210            .read()
8211            .external_tables
8212            .iter()
8213            .find(|t| t.name == name)
8214            .cloned()
8215    }
8216
8217    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
8218        self.create_external_table_inner(entry, None)
8219    }
8220
8221    pub fn create_external_table_controlled<F>(
8222        &self,
8223        entry: ExternalTableEntry,
8224        mut before_publish: F,
8225    ) -> Result<ExternalTableEntry>
8226    where
8227        F: FnMut() -> Result<()>,
8228    {
8229        self.create_external_table_inner(entry, Some(&mut before_publish))
8230    }
8231
8232    fn create_external_table_inner(
8233        &self,
8234        mut entry: ExternalTableEntry,
8235        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8236    ) -> Result<ExternalTableEntry> {
8237        self.require(&crate::auth::Permission::Ddl)?;
8238        let _g = self.ddl_lock.lock();
8239        let _security_write = self.security_write()?;
8240        self.require(&crate::auth::Permission::Ddl)?;
8241        entry.validate()?;
8242        {
8243            let cat = self.catalog.read();
8244            if cat.live(&entry.name).is_some()
8245                || cat.external_tables.iter().any(|t| t.name == entry.name)
8246            {
8247                return Err(MongrelError::InvalidArgument(format!(
8248                    "table {:?} already exists",
8249                    entry.name
8250                )));
8251            }
8252        }
8253        let commit_lock = Arc::clone(&self.commit_lock);
8254        let _c = commit_lock.lock();
8255        // A prior durable drop may have left connector state behind if its
8256        // cleanup failed or the process crashed. Never let a new table with
8257        // the same name inherit that stale state.
8258        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
8259        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
8260        let epoch = self.epoch.bump_assigned();
8261        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8262        entry.created_epoch = epoch.0;
8263        let mut next_catalog = self.catalog.read().clone();
8264        next_catalog.external_tables.push(entry.clone());
8265        next_catalog.db_epoch = epoch.0;
8266        self.publish_catalog_candidate_with_prelude(
8267            next_catalog,
8268            epoch,
8269            &mut _epoch_guard,
8270            before_publish,
8271            vec![(
8272                EXTERNAL_TABLE_ID,
8273                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
8274                    name: entry.name.clone(),
8275                    generation_epoch: epoch.0,
8276                }),
8277            )],
8278        )?;
8279        Ok(entry)
8280    }
8281
8282    pub fn drop_external_table(&self, name: &str) -> Result<()> {
8283        self.drop_external_table_with_epoch(name).map(|_| ())
8284    }
8285
8286    /// Drop an external table and return the exact catalog publication epoch.
8287    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
8288        self.drop_external_table_with_epoch_inner(name, None)
8289    }
8290
8291    pub fn drop_external_table_with_epoch_controlled<F>(
8292        &self,
8293        name: &str,
8294        mut before_publish: F,
8295    ) -> Result<Epoch>
8296    where
8297        F: FnMut() -> Result<()>,
8298    {
8299        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
8300    }
8301
8302    fn drop_external_table_with_epoch_inner(
8303        &self,
8304        name: &str,
8305        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8306    ) -> Result<Epoch> {
8307        self.require(&crate::auth::Permission::Ddl)?;
8308        let _g = self.ddl_lock.lock();
8309        let _security_write = self.security_write()?;
8310        self.require(&crate::auth::Permission::Ddl)?;
8311        let commit_lock = Arc::clone(&self.commit_lock);
8312        let _c = commit_lock.lock();
8313        let epoch = self.epoch.bump_assigned();
8314        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8315        let mut next_catalog = self.catalog.read().clone();
8316        let before = next_catalog.external_tables.len();
8317        next_catalog.external_tables.retain(|t| t.name != name);
8318        if next_catalog.external_tables.len() == before {
8319            return Err(MongrelError::NotFound(format!(
8320                "external table {name:?} not found"
8321            )));
8322        }
8323        next_catalog.db_epoch = epoch.0;
8324        self.publish_catalog_candidate_with_prelude(
8325            next_catalog,
8326            epoch,
8327            &mut _epoch_guard,
8328            before_publish,
8329            vec![(
8330                EXTERNAL_TABLE_ID,
8331                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
8332                    name: name.to_string(),
8333                    generation_epoch: epoch.0,
8334                }),
8335            )],
8336        )?;
8337        let state_dir = self.root.join(VTAB_DIR).join(name);
8338        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
8339            return Err(MongrelError::DurableCommit {
8340                epoch: epoch.0,
8341                message: format!(
8342                    "external table was dropped but connector-state cleanup failed: {error}"
8343                ),
8344            });
8345        }
8346        Ok(epoch)
8347    }
8348
8349    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
8350        let txn_id = self.alloc_txn_id()?;
8351        let (principal, catalog_bound) = self.transaction_principal_snapshot();
8352        self.commit_transaction_with_external_states(
8353            txn_id,
8354            self.epoch.visible(),
8355            Vec::new(),
8356            vec![(name.to_string(), state.to_vec())],
8357            Vec::new(),
8358            principal,
8359            catalog_bound,
8360            None,
8361            crate::txn::TxnCommitContext::internal(),
8362        )
8363        .map(|(epoch, _)| epoch)
8364    }
8365
8366    pub fn trigger_config(&self) -> TriggerConfig {
8367        use std::sync::atomic::Ordering;
8368        TriggerConfig {
8369            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
8370            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
8371            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
8372        }
8373    }
8374
8375    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
8376        use std::sync::atomic::Ordering;
8377        if config.max_depth == 0 {
8378            return Err(MongrelError::InvalidArgument(
8379                "trigger max_depth must be greater than 0".into(),
8380            ));
8381        }
8382        self.trigger_recursive
8383            .store(config.recursive_triggers, Ordering::Relaxed);
8384        self.trigger_max_depth
8385            .store(config.max_depth, Ordering::Relaxed);
8386        self.trigger_max_loop_iterations
8387            .store(config.max_loop_iterations, Ordering::Relaxed);
8388        Ok(())
8389    }
8390
8391    pub fn set_recursive_triggers(&self, recursive: bool) {
8392        use std::sync::atomic::Ordering;
8393        self.trigger_recursive.store(recursive, Ordering::Relaxed);
8394    }
8395
8396    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
8397    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
8398    /// as a low-latency wake-up.
8399    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
8400        self.notify.subscribe()
8401    }
8402
8403    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
8404        self.change_wake.subscribe()
8405    }
8406
8407    /// Reconstruct committed row changes from the retained shared WAL. Event
8408    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
8409    /// resumes before the oldest retained commit receives `gap = true` and
8410    /// must rebootstrap instead of silently skipping changes.
8411    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
8412        let control = crate::ExecutionControl::new(None);
8413        self.change_events_since_controlled(last_event_id, &control)
8414    }
8415
8416    /// Reconstruct committed changes with cooperative cancellation and bounds.
8417    pub fn change_events_since_controlled(
8418        &self,
8419        last_event_id: Option<&str>,
8420        control: &crate::ExecutionControl,
8421    ) -> Result<CdcBatch> {
8422        use crate::wal::Op;
8423
8424        control.checkpoint()?;
8425        let resume = match last_event_id {
8426            Some(id) => {
8427                let (epoch, index) = id.split_once(':').ok_or_else(|| {
8428                    MongrelError::InvalidArgument(format!(
8429                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
8430                    ))
8431                })?;
8432                Some((
8433                    epoch.parse::<u64>().map_err(|error| {
8434                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
8435                    })?,
8436                    index.parse::<u32>().map_err(|error| {
8437                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
8438                    })?,
8439                ))
8440            }
8441            None => None,
8442        };
8443
8444        let mut wal = self.shared_wal.lock();
8445        wal.group_sync()?;
8446        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
8447        let records = crate::wal::SharedWal::replay_with_dek_controlled(
8448            &self.root,
8449            wal_dek.as_ref(),
8450            control,
8451            CDC_MAX_WAL_RECORDS,
8452            CDC_MAX_WAL_REPLAY_BYTES,
8453        )?;
8454        drop(wal);
8455        control.checkpoint()?;
8456
8457        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
8458        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
8459        for (index, record) in records.iter().enumerate() {
8460            if index % 256 == 0 {
8461                control.checkpoint()?;
8462            }
8463            if let Op::TxnCommit { epoch, added_runs } = &record.op {
8464                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
8465            }
8466            if let Op::SpilledRows { table_id, rows } = &record.op {
8467                spilled_payloads
8468                    .entry((record.txn_id, *table_id))
8469                    .or_default()
8470                    .push(rows);
8471            }
8472        }
8473        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
8474        let current_epoch = self.epoch.committed().0;
8475        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
8476        let gap = resume.is_some_and(|(epoch, _)| {
8477            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
8478        });
8479        if gap {
8480            return Ok(CdcBatch {
8481                events: Vec::new(),
8482                current_epoch,
8483                earliest_epoch,
8484                gap: true,
8485            });
8486        }
8487
8488        let table_names: HashMap<u64, String> = self
8489            .catalog
8490            .read()
8491            .tables
8492            .iter()
8493            .map(|entry| (entry.table_id, entry.name.clone()))
8494            .collect();
8495        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
8496        let mut retained_bytes = 0_usize;
8497        for (index, record) in records.iter().enumerate() {
8498            if index % 256 == 0 {
8499                control.checkpoint()?;
8500            }
8501            if !commits.contains_key(&record.txn_id) {
8502                continue;
8503            }
8504            let Op::BeforeImage {
8505                table_id,
8506                row_id,
8507                row,
8508            } = &record.op
8509            else {
8510                continue;
8511            };
8512            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
8513                return Err(MongrelError::ResourceLimitExceeded {
8514                    resource: "CDC before-image bytes",
8515                    requested: row.len(),
8516                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
8517                });
8518            }
8519            let before: crate::memtable::Row = bincode::deserialize(row)?;
8520            if before_images.len() >= CDC_MAX_ROWS {
8521                return Err(MongrelError::ResourceLimitExceeded {
8522                    resource: "CDC before-image rows",
8523                    requested: before_images.len().saturating_add(1),
8524                    limit: CDC_MAX_ROWS,
8525                });
8526            }
8527            charge_cdc_bytes(
8528                &mut retained_bytes,
8529                cdc_row_storage_bytes(&before),
8530                "CDC retained bytes",
8531            )?;
8532            before_images.insert((record.txn_id, *table_id, row_id.0), before);
8533        }
8534        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
8535        let mut events = Vec::new();
8536        let mut decoded_rows = before_images.len();
8537        for (record_index, record) in records.iter().enumerate() {
8538            if record_index % 256 == 0 {
8539                control.checkpoint()?;
8540            }
8541            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
8542                continue;
8543            };
8544            let event = match &record.op {
8545                Op::Put { table_id, rows } => {
8546                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
8547                        return Err(MongrelError::ResourceLimitExceeded {
8548                            resource: "CDC inline row bytes",
8549                            requested: rows.len(),
8550                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
8551                        });
8552                    }
8553                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
8554                    decoded_rows = decoded_rows.saturating_add(rows.len());
8555                    if decoded_rows > CDC_MAX_ROWS {
8556                        return Err(MongrelError::ResourceLimitExceeded {
8557                            resource: "CDC decoded rows",
8558                            requested: decoded_rows,
8559                            limit: CDC_MAX_ROWS,
8560                        });
8561                    }
8562                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
8563                    let mut peak_bytes = retained_bytes;
8564                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
8565                    let data = serde_json::to_value(rows)
8566                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
8567                    Some((*table_id, "put", data, event_bytes))
8568                }
8569                Op::Delete { table_id, row_ids } => {
8570                    let before = row_ids
8571                        .iter()
8572                        .filter_map(|row_id| {
8573                            before_images
8574                                .get(&(record.txn_id, *table_id, row_id.0))
8575                                .cloned()
8576                        })
8577                        .collect::<Vec<_>>();
8578                    let event_bytes = cdc_rows_json_bytes(&before)
8579                        .saturating_add(
8580                            row_ids
8581                                .len()
8582                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
8583                        )
8584                        .saturating_add(512);
8585                    let mut peak_bytes = retained_bytes;
8586                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
8587                    Some((
8588                        *table_id,
8589                        "delete",
8590                        serde_json::json!({
8591                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
8592                            "before": before,
8593                        }),
8594                        event_bytes,
8595                    ))
8596                }
8597                Op::TruncateTable { table_id } => {
8598                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
8599                }
8600                _ => None,
8601            };
8602            if let Some((table_id, op, data, event_bytes)) = event {
8603                let index = operation_indices.entry(record.txn_id).or_insert(0);
8604                let event_position = (*commit_epoch, *index);
8605                *index = index.saturating_add(1);
8606                if resume.is_some_and(|position| event_position <= position) {
8607                    continue;
8608                }
8609                if events.len() >= CDC_MAX_EVENTS {
8610                    return Err(MongrelError::ResourceLimitExceeded {
8611                        resource: "CDC events",
8612                        requested: events.len().saturating_add(1),
8613                        limit: CDC_MAX_EVENTS,
8614                    });
8615                }
8616                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
8617                events.push(ChangeEvent {
8618                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
8619                    channel: "changes".into(),
8620                    table_id: Some(table_id),
8621                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
8622                    op: op.into(),
8623                    epoch: *commit_epoch,
8624                    txn_id: Some(record.txn_id),
8625                    message: None,
8626                    data: Some(data),
8627                });
8628            }
8629            if let Op::TxnCommit { added_runs, .. } = &record.op {
8630                for run in added_runs {
8631                    control.checkpoint()?;
8632                    let index = operation_indices.entry(record.txn_id).or_insert(0);
8633                    let event_position = (*commit_epoch, *index);
8634                    *index = index.saturating_add(1);
8635                    if resume.is_some_and(|position| event_position <= position) {
8636                        continue;
8637                    }
8638                    let mut rows = if let Some(payloads) =
8639                        spilled_payloads.get(&(record.txn_id, run.table_id))
8640                    {
8641                        let mut rows = Vec::new();
8642                        for payload in payloads {
8643                            control.checkpoint()?;
8644                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
8645                                return Err(MongrelError::ResourceLimitExceeded {
8646                                    resource: "CDC spilled row bytes",
8647                                    requested: payload.len(),
8648                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
8649                                });
8650                            }
8651                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
8652                            if decoded_rows
8653                                .saturating_add(rows.len())
8654                                .saturating_add(chunk.len())
8655                                > CDC_MAX_ROWS
8656                            {
8657                                return Err(MongrelError::ResourceLimitExceeded {
8658                                    resource: "CDC decoded rows",
8659                                    requested: decoded_rows
8660                                        .saturating_add(rows.len())
8661                                        .saturating_add(chunk.len()),
8662                                    limit: CDC_MAX_ROWS,
8663                                });
8664                            }
8665                            rows.extend(chunk);
8666                        }
8667                        rows
8668                    } else {
8669                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
8670                            return Ok(CdcBatch {
8671                                events: Vec::new(),
8672                                current_epoch,
8673                                earliest_epoch,
8674                                gap: true,
8675                            });
8676                        };
8677                        let table = handle.lock();
8678                        let mut reader = match table.open_reader(run.run_id) {
8679                            Ok(reader) => reader,
8680                            Err(_) => {
8681                                return Ok(CdcBatch {
8682                                    events: Vec::new(),
8683                                    current_epoch,
8684                                    earliest_epoch,
8685                                    gap: true,
8686                                })
8687                            }
8688                        };
8689                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
8690                        let rows = reader.all_rows_controlled(control, remaining)?;
8691                        drop(reader);
8692                        drop(table);
8693                        rows
8694                    };
8695                    for row in &mut rows {
8696                        row.committed_epoch = Epoch(*commit_epoch);
8697                    }
8698                    decoded_rows = decoded_rows.saturating_add(rows.len());
8699                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
8700                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
8701                    if events.len() >= CDC_MAX_EVENTS {
8702                        return Err(MongrelError::ResourceLimitExceeded {
8703                            resource: "CDC events",
8704                            requested: events.len().saturating_add(1),
8705                            limit: CDC_MAX_EVENTS,
8706                        });
8707                    }
8708                    events.push(ChangeEvent {
8709                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
8710                        channel: "changes".into(),
8711                        table_id: Some(run.table_id),
8712                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
8713                        op: "put_run".into(),
8714                        epoch: *commit_epoch,
8715                        txn_id: Some(record.txn_id),
8716                        message: None,
8717                        data: Some(serde_json::json!({
8718                            "run_id": run.run_id.to_string(),
8719                            "row_count": run.row_count,
8720                            "min_row_id": run.min_row_id,
8721                            "max_row_id": run.max_row_id,
8722                            "rows": rows,
8723                        })),
8724                    });
8725                }
8726            }
8727        }
8728        control.checkpoint()?;
8729        Ok(CdcBatch {
8730            events,
8731            current_epoch,
8732            earliest_epoch,
8733            gap: false,
8734        })
8735    }
8736
8737    /// Publish a notification message on a named channel. Reaches all active
8738    /// subscribers (daemon `/events`, application listeners).
8739    pub fn notify(&self, channel: &str, message: Option<String>) {
8740        let _ = self.notify.send(ChangeEvent {
8741            id: None,
8742            channel: channel.to_string(),
8743            table_id: None,
8744            table: String::new(),
8745            op: "notify".into(),
8746            epoch: self.epoch.visible().0,
8747            txn_id: None,
8748            message,
8749            data: None,
8750        });
8751    }
8752
8753    pub fn call_procedure(
8754        &self,
8755        name: &str,
8756        args: HashMap<String, crate::Value>,
8757    ) -> Result<ProcedureCallResult> {
8758        self.call_procedure_as(name, args, None)
8759    }
8760
8761    pub fn call_procedure_as(
8762        &self,
8763        name: &str,
8764        args: HashMap<String, crate::Value>,
8765        principal: Option<&crate::auth::Principal>,
8766    ) -> Result<ProcedureCallResult> {
8767        let control = crate::ExecutionControl::new(None);
8768        self.call_procedure_as_controlled(name, args, principal, &control, || true)
8769    }
8770
8771    /// Execute only the exact procedure revision previously authorized by the
8772    /// caller. A dropped or replaced definition fails closed.
8773    #[doc(hidden)]
8774    pub fn call_procedure_as_bound(
8775        &self,
8776        expected: &StoredProcedure,
8777        args: HashMap<String, crate::Value>,
8778        principal: Option<&crate::auth::Principal>,
8779    ) -> Result<ProcedureCallResult> {
8780        self.require_for(principal, &crate::auth::Permission::All)?;
8781        let procedure = self.procedure(&expected.name).ok_or_else(|| {
8782            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
8783        })?;
8784        if &procedure != expected {
8785            return Err(MongrelError::Conflict(format!(
8786                "procedure {:?} changed after request authorization",
8787                expected.name
8788            )));
8789        }
8790        let control = crate::ExecutionControl::new(None);
8791        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
8792    }
8793
8794    /// Execute a procedure with cooperative cancellation during preparation.
8795    /// `before_commit` runs after every procedure step has succeeded and
8796    /// immediately before a write procedure commits. Returning `false` aborts
8797    /// the transaction without publishing it.
8798    #[doc(hidden)]
8799    pub fn call_procedure_as_controlled<F>(
8800        &self,
8801        name: &str,
8802        args: HashMap<String, crate::Value>,
8803        principal: Option<&crate::auth::Principal>,
8804        control: &crate::ExecutionControl,
8805        before_commit: F,
8806    ) -> Result<ProcedureCallResult>
8807    where
8808        F: FnOnce() -> bool,
8809    {
8810        // v1 requires ALL to call procedures on a require_auth database; a
8811        // finer SECURITY DEFINER-style marker is a future extension (spec §9
8812        // decision 1).
8813        self.require_for(principal, &crate::auth::Permission::All)?;
8814        let procedure = self
8815            .procedure(name)
8816            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
8817        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
8818    }
8819
8820    fn execute_procedure_as_controlled<F>(
8821        &self,
8822        procedure: StoredProcedure,
8823        args: HashMap<String, crate::Value>,
8824        principal: Option<&crate::auth::Principal>,
8825        control: &crate::ExecutionControl,
8826        before_commit: F,
8827    ) -> Result<ProcedureCallResult>
8828    where
8829        F: FnOnce() -> bool,
8830    {
8831        let args = bind_procedure_args(&procedure, args)?;
8832        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
8833        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
8834        if has_writes {
8835            let mut tx = self.begin_as(principal.cloned());
8836            let run = (|| {
8837                for (step_index, step) in procedure.body.steps.iter().enumerate() {
8838                    if step_index % 256 == 0 {
8839                        control.checkpoint()?;
8840                    }
8841                    let output = self.execute_procedure_step(
8842                        step,
8843                        &args,
8844                        &outputs,
8845                        Some(&mut tx),
8846                        principal,
8847                        Some(control),
8848                    )?;
8849                    outputs.insert(step.id().to_string(), output);
8850                }
8851                control.checkpoint()?;
8852                eval_return_output(&procedure.body.return_value, &args, &outputs)
8853            })();
8854            match run {
8855                Ok(output) => {
8856                    control.checkpoint()?;
8857                    if !before_commit() {
8858                        tx.rollback();
8859                        return Err(MongrelError::Cancelled);
8860                    }
8861                    let epoch = tx.commit()?.0;
8862                    Ok(ProcedureCallResult {
8863                        epoch: Some(epoch),
8864                        output,
8865                    })
8866                }
8867                Err(e) => {
8868                    tx.rollback();
8869                    Err(e)
8870                }
8871            }
8872        } else {
8873            for (step_index, step) in procedure.body.steps.iter().enumerate() {
8874                if step_index % 256 == 0 {
8875                    control.checkpoint()?;
8876                }
8877                let output = self.execute_procedure_step(
8878                    step,
8879                    &args,
8880                    &outputs,
8881                    None,
8882                    principal,
8883                    Some(control),
8884                )?;
8885                outputs.insert(step.id().to_string(), output);
8886            }
8887            control.checkpoint()?;
8888            Ok(ProcedureCallResult {
8889                epoch: None,
8890                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
8891            })
8892        }
8893    }
8894
8895    fn execute_procedure_step(
8896        &self,
8897        step: &ProcedureStep,
8898        args: &HashMap<String, crate::Value>,
8899        outputs: &HashMap<String, ProcedureCallOutput>,
8900        tx: Option<&mut crate::txn::Transaction<'_>>,
8901        principal: Option<&crate::auth::Principal>,
8902        control: Option<&crate::ExecutionControl>,
8903    ) -> Result<ProcedureCallOutput> {
8904        if let Some(control) = control {
8905            control.checkpoint()?;
8906        }
8907        match step {
8908            ProcedureStep::NativeQuery {
8909                table,
8910                conditions,
8911                projection,
8912                limit,
8913                ..
8914            } => {
8915                let mut q = crate::Query::new();
8916                for condition in conditions {
8917                    q = q.and(eval_condition(condition, args, outputs)?);
8918                }
8919                let fallback_control = crate::ExecutionControl::new(None);
8920                let query_control = control.unwrap_or(&fallback_control);
8921                let mut rows = self.query_for_principal_controlled(
8922                    table,
8923                    &q,
8924                    projection.as_deref(),
8925                    principal,
8926                    false,
8927                    query_control,
8928                )?;
8929                if let Some(limit) = limit {
8930                    rows.truncate(*limit);
8931                }
8932                let mut output = Vec::with_capacity(rows.len());
8933                for (row_index, row) in rows.into_iter().enumerate() {
8934                    if row_index % 256 == 0 {
8935                        if let Some(control) = control {
8936                            control.checkpoint()?;
8937                        }
8938                    }
8939                    output.push(ProcedureCallRow {
8940                        row_id: Some(row.row_id),
8941                        columns: row.columns,
8942                    });
8943                }
8944                Ok(ProcedureCallOutput::Rows(output))
8945            }
8946            ProcedureStep::Put {
8947                table,
8948                cells,
8949                returning,
8950                ..
8951            } => {
8952                let tx = tx.ok_or_else(|| {
8953                    MongrelError::InvalidArgument(
8954                        "write procedure step requires a transaction".into(),
8955                    )
8956                })?;
8957                let cells = eval_cells(cells, args, outputs)?;
8958                if *returning {
8959                    let out = tx.put_returning(table, cells)?;
8960                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
8961                        row_id: None,
8962                        columns: out.row.columns.into_iter().collect(),
8963                    }))
8964                } else {
8965                    tx.put(table, cells)?;
8966                    Ok(ProcedureCallOutput::Null)
8967                }
8968            }
8969            ProcedureStep::Upsert {
8970                table,
8971                cells,
8972                update_cells,
8973                returning,
8974                ..
8975            } => {
8976                let tx = tx.ok_or_else(|| {
8977                    MongrelError::InvalidArgument(
8978                        "write procedure step requires a transaction".into(),
8979                    )
8980                })?;
8981                let cells = eval_cells(cells, args, outputs)?;
8982                let action = match update_cells {
8983                    Some(update_cells) => {
8984                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
8985                    }
8986                    None => crate::UpsertAction::DoNothing,
8987                };
8988                let out = tx.upsert(table, cells, action)?;
8989                if *returning {
8990                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
8991                        row_id: None,
8992                        columns: out.row.columns.into_iter().collect(),
8993                    }))
8994                } else {
8995                    Ok(ProcedureCallOutput::Null)
8996                }
8997            }
8998            ProcedureStep::DeleteByPk { table, pk, .. } => {
8999                let tx = tx.ok_or_else(|| {
9000                    MongrelError::InvalidArgument(
9001                        "write procedure step requires a transaction".into(),
9002                    )
9003                })?;
9004                let pk = eval_value(pk, args, outputs)?;
9005                let handle = self.table(table)?;
9006                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
9007                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
9008                })?;
9009                tx.delete(table, row_id)?;
9010                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
9011            }
9012            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
9013                "DeleteRows procedure step is not supported by the core executor yet".into(),
9014            )),
9015            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
9016                "SqlQuery procedure step must be executed by mongreldb-query".into(),
9017            )),
9018        }
9019    }
9020
9021    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
9022        let cat = self.catalog.read();
9023        for step in &procedure.body.steps {
9024            let Some(table_name) = step.table() else {
9025                continue;
9026            };
9027            let schema = &cat
9028                .live(table_name)
9029                .ok_or_else(|| {
9030                    MongrelError::InvalidArgument(format!(
9031                        "procedure {:?} references unknown table {table_name:?}",
9032                        procedure.name
9033                    ))
9034                })?
9035                .schema;
9036            match step {
9037                ProcedureStep::NativeQuery {
9038                    conditions,
9039                    projection,
9040                    ..
9041                } => {
9042                    for condition in conditions {
9043                        validate_condition_columns(condition, schema)?;
9044                    }
9045                    if let Some(projection) = projection {
9046                        for id in projection {
9047                            validate_column_id(*id, schema)?;
9048                        }
9049                    }
9050                }
9051                ProcedureStep::Put { cells, .. } => {
9052                    for cell in cells {
9053                        validate_column_id(cell.column_id, schema)?;
9054                    }
9055                }
9056                ProcedureStep::Upsert {
9057                    cells,
9058                    update_cells,
9059                    ..
9060                } => {
9061                    for cell in cells {
9062                        validate_column_id(cell.column_id, schema)?;
9063                    }
9064                    if let Some(update_cells) = update_cells {
9065                        for cell in update_cells {
9066                            validate_column_id(cell.column_id, schema)?;
9067                        }
9068                    }
9069                }
9070                ProcedureStep::DeleteByPk { .. } => {
9071                    if schema.primary_key().is_none() {
9072                        return Err(MongrelError::InvalidArgument(format!(
9073                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
9074                            procedure.name
9075                        )));
9076                    }
9077                }
9078                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
9079            }
9080        }
9081        Ok(())
9082    }
9083
9084    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
9085        let cat = self.catalog.read();
9086        let target_schema = match &trigger.target {
9087            TriggerTarget::Table(target_name) => cat
9088                .live(target_name)
9089                .ok_or_else(|| {
9090                    MongrelError::InvalidArgument(format!(
9091                        "trigger {:?} references unknown target table {target_name:?}",
9092                        trigger.name
9093                    ))
9094                })?
9095                .schema
9096                .clone(),
9097            TriggerTarget::View(_) => Schema {
9098                columns: trigger.target_columns.clone(),
9099                ..Schema::default()
9100            },
9101        };
9102        for col in &trigger.update_of {
9103            if target_schema.column(col).is_none() {
9104                return Err(MongrelError::InvalidArgument(format!(
9105                    "trigger {:?} UPDATE OF references unknown column {col:?}",
9106                    trigger.name
9107                )));
9108            }
9109        }
9110        if let Some(expr) = &trigger.when {
9111            validate_trigger_expr(expr, &target_schema, trigger.event)?;
9112        }
9113        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
9114        for step in &trigger.program.steps {
9115            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
9116            {
9117                return Err(MongrelError::InvalidArgument(
9118                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
9119                ));
9120            }
9121            validate_trigger_step(
9122                step,
9123                &cat,
9124                &target_schema,
9125                trigger.event,
9126                &mut select_schemas,
9127            )?;
9128        }
9129        Ok(())
9130    }
9131
9132    /// Begin a new transaction reading at the current visible epoch.
9133    pub fn begin(&self) -> crate::txn::Transaction<'_> {
9134        self.begin_with_isolation(crate::txn::IsolationLevel::default())
9135    }
9136
9137    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
9138        let principal = self.principal.read().clone();
9139        let catalog_bound = principal.as_ref().is_some_and(|principal| {
9140            let catalog = self.catalog.read();
9141            catalog.require_auth || principal.user_id != 0
9142        });
9143        (principal, catalog_bound)
9144    }
9145
9146    pub fn begin_as(
9147        &self,
9148        principal: Option<crate::auth::Principal>,
9149    ) -> crate::txn::Transaction<'_> {
9150        let catalog_bound = principal.as_ref().is_some_and(|principal| {
9151            let catalog = self.catalog.read();
9152            catalog.require_auth || principal.user_id != 0
9153        });
9154        let txn_id = self.alloc_txn_id();
9155        let read = Snapshot::at(self.epoch.visible());
9156        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
9157            .with_principal(principal, catalog_bound)
9158    }
9159
9160    /// Begin a transaction with a specific isolation level.
9161    pub fn begin_with_isolation(
9162        &self,
9163        level: crate::txn::IsolationLevel,
9164    ) -> crate::txn::Transaction<'_> {
9165        let txn_id = self.alloc_txn_id();
9166        // Every level pins the current visible epoch at begin; ReadCommitted
9167        // re-pins per statement inside the transaction (S1B-002).
9168        let read = Snapshot::at(self.epoch.visible());
9169        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9170        crate::txn::Transaction::new(self, txn_id, read, level)
9171            .with_principal(principal, catalog_bound)
9172    }
9173
9174    /// Begin a transaction whose trigger programs may route external-table DML
9175    /// through an application/query-layer module bridge.
9176    pub fn begin_with_external_trigger_bridge<'a>(
9177        &'a self,
9178        bridge: &'a dyn ExternalTriggerBridge,
9179    ) -> crate::txn::Transaction<'a> {
9180        let txn_id = self.alloc_txn_id();
9181        let read = Snapshot::at(self.epoch.visible());
9182        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9183        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
9184            .with_external_trigger_bridge(bridge)
9185            .with_principal(principal, catalog_bound)
9186    }
9187
9188    pub fn begin_with_external_trigger_bridge_as<'a>(
9189        &'a self,
9190        bridge: &'a dyn ExternalTriggerBridge,
9191        principal: Option<crate::auth::Principal>,
9192    ) -> crate::txn::Transaction<'a> {
9193        let catalog_bound = principal.as_ref().is_some_and(|principal| {
9194            let catalog = self.catalog.read();
9195            catalog.require_auth || principal.user_id != 0
9196        });
9197        let txn_id = self.alloc_txn_id();
9198        let read = Snapshot::at(self.epoch.visible());
9199        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
9200            .with_external_trigger_bridge(bridge)
9201            .with_principal(principal, catalog_bound)
9202    }
9203
9204    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
9205    pub fn transaction<T>(
9206        &self,
9207        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9208    ) -> Result<T> {
9209        let mut tx = self.begin();
9210        match f(&mut tx) {
9211            Ok(out) => {
9212                tx.commit()?;
9213                Ok(out)
9214            }
9215            Err(e) => {
9216                tx.rollback();
9217                Err(e)
9218            }
9219        }
9220    }
9221
9222    pub fn transaction_with_row_ids<T>(
9223        &self,
9224        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9225    ) -> Result<(T, Vec<RowId>)> {
9226        let mut tx = self.begin();
9227        match f(&mut tx) {
9228            Ok(output) => {
9229                let (_, row_ids) = tx.commit_with_row_ids()?;
9230                Ok((output, row_ids))
9231            }
9232            Err(error) => {
9233                tx.rollback();
9234                Err(error)
9235            }
9236        }
9237    }
9238
9239    pub fn transaction_for_current_principal<T>(
9240        &self,
9241        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9242    ) -> Result<T> {
9243        if self.principal.read().is_some() {
9244            self.refresh_principal()?;
9245        }
9246        let mut transaction = self.begin_as(self.principal.read().clone());
9247        match f(&mut transaction) {
9248            Ok(output) => {
9249                transaction.commit()?;
9250                Ok(output)
9251            }
9252            Err(error) => {
9253                transaction.rollback();
9254                Err(error)
9255            }
9256        }
9257    }
9258
9259    pub fn transaction_for_current_principal_with_epoch<T>(
9260        &self,
9261        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9262    ) -> Result<(Epoch, T)> {
9263        if self.principal.read().is_some() {
9264            self.refresh_principal()?;
9265        }
9266        let mut transaction = self.begin_as(self.principal.read().clone());
9267        match f(&mut transaction) {
9268            Ok(output) => {
9269                let epoch = transaction.commit()?;
9270                Ok((epoch, output))
9271            }
9272            Err(error) => {
9273                transaction.rollback();
9274                Err(error)
9275            }
9276        }
9277    }
9278
9279    pub fn transaction_with_row_ids_for_current_principal<T>(
9280        &self,
9281        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9282    ) -> Result<(T, Vec<RowId>)> {
9283        if self.principal.read().is_some() {
9284            self.refresh_principal()?;
9285        }
9286        let mut transaction = self.begin_as(self.principal.read().clone());
9287        match f(&mut transaction) {
9288            Ok(output) => {
9289                let (_, row_ids) = transaction.commit_with_row_ids()?;
9290                Ok((output, row_ids))
9291            }
9292            Err(error) => {
9293                transaction.rollback();
9294                Err(error)
9295            }
9296        }
9297    }
9298
9299    /// Run `f` in a transaction with an external-trigger bridge; commit on
9300    /// `Ok`, rollback on `Err`.
9301    pub fn transaction_with_external_trigger_bridge<'a, T>(
9302        &'a self,
9303        bridge: &'a dyn ExternalTriggerBridge,
9304        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9305    ) -> Result<T> {
9306        let mut tx = self.begin_with_external_trigger_bridge(bridge);
9307        match f(&mut tx) {
9308            Ok(out) => {
9309                tx.commit()?;
9310                Ok(out)
9311            }
9312            Err(e) => {
9313                tx.rollback();
9314                Err(e)
9315            }
9316        }
9317    }
9318
9319    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
9320        &'a self,
9321        bridge: &'a dyn ExternalTriggerBridge,
9322        principal: Option<crate::auth::Principal>,
9323        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9324    ) -> Result<T> {
9325        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
9326        match f(&mut tx) {
9327            Ok(output) => {
9328                tx.commit()?;
9329                Ok(output)
9330            }
9331            Err(error) => {
9332                tx.rollback();
9333                Err(error)
9334            }
9335        }
9336    }
9337
9338    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
9339    /// `Transaction::new` so registration happens **before** any read.
9340    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
9341        self.active_txns.register(epoch)
9342    }
9343
9344    fn fill_auto_increment_for_staging(
9345        &self,
9346        txn_id: u64,
9347        staging: &mut [(u64, crate::txn::Staged)],
9348        control: Option<&crate::ExecutionControl>,
9349    ) -> Result<()> {
9350        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9351        for (index, (table_id, staged)) in staging.iter().enumerate() {
9352            commit_prepare_checkpoint(control, index)?;
9353            if matches!(staged, crate::txn::Staged::Put(_)) {
9354                puts_by_table.entry(*table_id).or_default().push(index);
9355            }
9356        }
9357
9358        // S1B-003: sequence allocation serializes on one Exclusive barrier per
9359        // staged table whose puts actually ALLOCATE an auto-increment value
9360        // (absent/Null column — explicit values only advance the counter and
9361        // must not serialize), held until the transaction ends so allocated
9362        // values map monotonically onto commit order. The barrier is acquired
9363        // before the table lock (never the reverse).
9364        {
9365            let mut barrier_tables: Vec<u64> = puts_by_table
9366                .iter()
9367                .filter(|(table_id, indexes)| {
9368                    indexes.iter().any(|index| {
9369                        matches!(
9370                            &staging[*index].1,
9371                            crate::txn::Staged::Put(cells)
9372                                if self.table_auto_inc_would_allocate(**table_id, cells)
9373                        )
9374                    })
9375                })
9376                .map(|(table_id, _)| *table_id)
9377                .collect();
9378            barrier_tables.sort_unstable();
9379            for table_id in barrier_tables {
9380                self.acquire_txn_lock(
9381                    txn_id,
9382                    crate::locks::LockKey::sequence_barrier(&format!("auto_inc:{table_id}")),
9383                    crate::locks::LockMode::Exclusive,
9384                    control,
9385                )?;
9386            }
9387        }
9388
9389        let tables = self.tables.read();
9390        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
9391            commit_prepare_checkpoint(control, table_index)?;
9392            if let Some(handle) = tables.get(&table_id) {
9393                #[cfg(test)]
9394                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9395                let mut t = handle.lock();
9396                for (fill_index, index) in indexes.into_iter().enumerate() {
9397                    commit_prepare_checkpoint(control, fill_index)?;
9398                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
9399                        t.fill_auto_inc(cells)?;
9400                    }
9401                }
9402            }
9403        }
9404        Ok(())
9405    }
9406
9407    fn expand_table_triggers(
9408        &self,
9409        txn_id: u64,
9410        staging: &mut Vec<(u64, crate::txn::Staged)>,
9411        read_epoch: Epoch,
9412        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9413        external_states: &mut Vec<(String, Vec<u8>)>,
9414        control: Option<&crate::ExecutionControl>,
9415    ) -> Result<()> {
9416        commit_prepare_checkpoint(control, 0)?;
9417        let mut external_writes = Vec::new();
9418        let config = self.trigger_config();
9419        if config.recursive_triggers {
9420            let chunk = std::mem::take(staging);
9421            let stacks = vec![Vec::new(); chunk.len()];
9422            *staging = self.expand_trigger_chunk(
9423                txn_id,
9424                chunk,
9425                stacks,
9426                read_epoch,
9427                0,
9428                config.max_depth,
9429                &mut external_writes,
9430                &config,
9431                control,
9432            )?;
9433            self.apply_external_trigger_writes(
9434                external_writes,
9435                external_trigger_bridge,
9436                external_states,
9437                staging,
9438                control,
9439            )?;
9440            return Ok(());
9441        }
9442
9443        let mut expansion =
9444            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
9445        if !expansion.before.is_empty() {
9446            let mut final_staging = expansion.before;
9447            final_staging.extend(filter_ignored_staging(
9448                std::mem::take(staging),
9449                &expansion.ignored_indices,
9450            ));
9451            *staging = final_staging;
9452        } else if !expansion.ignored_indices.is_empty() {
9453            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
9454        }
9455        staging.append(&mut expansion.after);
9456        external_writes.append(&mut expansion.before_external);
9457        external_writes.append(&mut expansion.after_external);
9458        self.apply_external_trigger_writes(
9459            external_writes,
9460            external_trigger_bridge,
9461            external_states,
9462            staging,
9463            control,
9464        )?;
9465        Ok(())
9466    }
9467
9468    #[allow(clippy::too_many_arguments)]
9469    fn expand_trigger_chunk(
9470        &self,
9471        txn_id: u64,
9472        mut chunk: Vec<(u64, crate::txn::Staged)>,
9473        stacks: Vec<Vec<String>>,
9474        read_epoch: Epoch,
9475        depth: u32,
9476        max_depth: u32,
9477        external_writes: &mut Vec<ExternalTriggerWrite>,
9478        config: &TriggerConfig,
9479        control: Option<&crate::ExecutionControl>,
9480    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
9481        if chunk.is_empty() {
9482            return Ok(Vec::new());
9483        }
9484        commit_prepare_checkpoint(control, 0)?;
9485        self.fill_auto_increment_for_staging(txn_id, &mut chunk, control)?;
9486        let expansion = self.expand_table_triggers_once(
9487            &mut chunk,
9488            read_epoch,
9489            Some(&stacks),
9490            config,
9491            control,
9492        )?;
9493        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
9494            let stack = expansion
9495                .before_stacks
9496                .first()
9497                .or_else(|| expansion.after_stacks.first())
9498                .cloned()
9499                .unwrap_or_default();
9500            return Err(MongrelError::TriggerValidation(format!(
9501                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
9502                Self::format_trigger_stack(&stack)
9503            )));
9504        }
9505
9506        let mut out = Vec::new();
9507        external_writes.extend(expansion.before_external);
9508        out.extend(self.expand_trigger_chunk(
9509            txn_id,
9510            expansion.before,
9511            expansion.before_stacks,
9512            read_epoch,
9513            depth + 1,
9514            max_depth,
9515            external_writes,
9516            config,
9517            control,
9518        )?);
9519        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
9520        external_writes.extend(expansion.after_external);
9521        out.extend(self.expand_trigger_chunk(
9522            txn_id,
9523            expansion.after,
9524            expansion.after_stacks,
9525            read_epoch,
9526            depth + 1,
9527            max_depth,
9528            external_writes,
9529            config,
9530            control,
9531        )?);
9532        Ok(out)
9533    }
9534
9535    fn apply_external_trigger_writes(
9536        &self,
9537        writes: Vec<ExternalTriggerWrite>,
9538        bridge: Option<&dyn ExternalTriggerBridge>,
9539        external_states: &mut Vec<(String, Vec<u8>)>,
9540        staging: &mut Vec<(u64, crate::txn::Staged)>,
9541        control: Option<&crate::ExecutionControl>,
9542    ) -> Result<()> {
9543        if writes.is_empty() {
9544            return Ok(());
9545        }
9546        let bridge = bridge.ok_or_else(|| {
9547            MongrelError::TriggerValidation(
9548                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
9549            )
9550        })?;
9551        for (write_index, write) in writes.into_iter().enumerate() {
9552            commit_prepare_checkpoint(control, write_index)?;
9553            let table = write.table().to_string();
9554            let entry = self.external_table(&table).ok_or_else(|| {
9555                MongrelError::NotFound(format!("external table {table:?} not found"))
9556            })?;
9557            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
9558            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
9559            external_states.push((table, result.state));
9560            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
9561                commit_prepare_checkpoint(control, base_index)?;
9562                match base_write {
9563                    ExternalTriggerBaseWrite::Put { table, cells } => {
9564                        let table_id = self.table_id(&table)?;
9565                        staging.push((table_id, crate::txn::Staged::Put(cells)));
9566                    }
9567                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
9568                        let table_id = self.table_id(&table)?;
9569                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
9570                    }
9571                }
9572            }
9573        }
9574        dedup_external_states_in_place(external_states);
9575        Ok(())
9576    }
9577
9578    fn expand_table_triggers_once(
9579        &self,
9580        staging: &mut Vec<(u64, crate::txn::Staged)>,
9581        read_epoch: Epoch,
9582        trigger_stacks: Option<&[Vec<String>]>,
9583        config: &TriggerConfig,
9584        control: Option<&crate::ExecutionControl>,
9585    ) -> Result<TriggerExpansion> {
9586        commit_prepare_checkpoint(control, 0)?;
9587        let triggers: Vec<StoredTrigger> = self
9588            .catalog
9589            .read()
9590            .triggers
9591            .iter()
9592            .filter(|entry| {
9593                entry.trigger.enabled
9594                    && matches!(
9595                        entry.trigger.timing,
9596                        TriggerTiming::Before | TriggerTiming::After
9597                    )
9598                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
9599            })
9600            .map(|entry| entry.trigger.clone())
9601            .collect();
9602        if triggers.is_empty() || staging.is_empty() {
9603            return Ok(TriggerExpansion::default());
9604        }
9605
9606        let before_triggers = triggers
9607            .iter()
9608            .filter(|trigger| trigger.timing == TriggerTiming::Before)
9609            .cloned()
9610            .collect::<Vec<_>>();
9611        let after_triggers = triggers
9612            .iter()
9613            .filter(|trigger| trigger.timing == TriggerTiming::After)
9614            .cloned()
9615            .collect::<Vec<_>>();
9616
9617        let mut before_added = Vec::new();
9618        let mut before_stacks = Vec::new();
9619        let mut before_external = Vec::new();
9620        let mut ignored_indices = std::collections::BTreeSet::new();
9621        if !before_triggers.is_empty() {
9622            let before_events =
9623                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
9624            let mut out = TriggerProgramOutput {
9625                added: &mut before_added,
9626                added_stacks: &mut before_stacks,
9627                added_external: &mut before_external,
9628                ignored_indices: &mut ignored_indices,
9629            };
9630            self.execute_triggers_for_events(
9631                &before_triggers,
9632                &before_events,
9633                Some(staging),
9634                &mut out,
9635                config,
9636                read_epoch,
9637                control,
9638            )?;
9639        }
9640
9641        let after_events = if after_triggers.is_empty() {
9642            Vec::new()
9643        } else {
9644            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
9645                .into_iter()
9646                .filter(|event| {
9647                    !event
9648                        .op_indices
9649                        .iter()
9650                        .any(|idx| ignored_indices.contains(idx))
9651                })
9652                .collect()
9653        };
9654
9655        let mut after_added = Vec::new();
9656        let mut after_stacks = Vec::new();
9657        let mut after_external = Vec::new();
9658        let mut out = TriggerProgramOutput {
9659            added: &mut after_added,
9660            added_stacks: &mut after_stacks,
9661            added_external: &mut after_external,
9662            ignored_indices: &mut ignored_indices,
9663        };
9664        self.execute_triggers_for_events(
9665            &after_triggers,
9666            &after_events,
9667            None,
9668            &mut out,
9669            config,
9670            read_epoch,
9671            control,
9672        )?;
9673        Ok(TriggerExpansion {
9674            before: before_added,
9675            before_stacks,
9676            before_external,
9677            after: after_added,
9678            after_stacks,
9679            after_external,
9680            ignored_indices,
9681        })
9682    }
9683
9684    #[allow(clippy::too_many_arguments)]
9685    fn execute_triggers_for_events(
9686        &self,
9687        triggers: &[StoredTrigger],
9688        events: &[WriteEvent],
9689        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
9690        out: &mut TriggerProgramOutput<'_>,
9691        config: &TriggerConfig,
9692        read_epoch: Epoch,
9693        control: Option<&crate::ExecutionControl>,
9694    ) -> Result<()> {
9695        let mut checkpoint_index = 0_usize;
9696        for event in events {
9697            for trigger in triggers {
9698                commit_prepare_checkpoint(control, checkpoint_index)?;
9699                checkpoint_index += 1;
9700                if event
9701                    .op_indices
9702                    .iter()
9703                    .any(|idx| out.ignored_indices.contains(idx))
9704                {
9705                    break;
9706                }
9707                let matches = {
9708                    let cat = self.catalog.read();
9709                    trigger_matches_event(trigger, event, &cat)?
9710                };
9711                if !matches {
9712                    continue;
9713                }
9714                if let Some(when) = &trigger.when {
9715                    if !eval_trigger_expr(when, event)? {
9716                        continue;
9717                    }
9718                }
9719                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
9720                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
9721                    return Err(MongrelError::TriggerValidation(format!(
9722                        "trigger recursion cycle detected; trigger stack: {}",
9723                        Self::format_trigger_stack(&trigger_stack)
9724                    )));
9725                }
9726                let outcome = match staging.as_mut() {
9727                    Some(staging) => self.execute_trigger_program(
9728                        trigger,
9729                        event,
9730                        Some(&mut **staging),
9731                        out,
9732                        &trigger_stack,
9733                        config,
9734                        read_epoch,
9735                        control,
9736                    )?,
9737                    None => self.execute_trigger_program(
9738                        trigger,
9739                        event,
9740                        None,
9741                        out,
9742                        &trigger_stack,
9743                        config,
9744                        read_epoch,
9745                        control,
9746                    )?,
9747                };
9748                if outcome == TriggerProgramOutcome::Ignore {
9749                    out.ignored_indices.extend(event.op_indices.iter().copied());
9750                    break;
9751                }
9752            }
9753        }
9754        Ok(())
9755    }
9756
9757    fn trigger_events_for_staging(
9758        &self,
9759        staging: &[(u64, crate::txn::Staged)],
9760        read_epoch: Epoch,
9761        trigger_stacks: Option<&[Vec<String>]>,
9762        control: Option<&crate::ExecutionControl>,
9763    ) -> Result<Vec<WriteEvent>> {
9764        use crate::txn::Staged;
9765        use std::collections::{HashMap, VecDeque};
9766
9767        let snapshot = Snapshot::at(read_epoch);
9768        let cat = self.catalog.read();
9769        let mut table_names = HashMap::new();
9770        let mut table_schemas = HashMap::new();
9771        for entry in cat
9772            .tables
9773            .iter()
9774            .filter(|entry| matches!(entry.state, TableState::Live))
9775        {
9776            table_names.insert(entry.table_id, entry.name.clone());
9777            table_schemas.insert(entry.table_id, entry.schema.clone());
9778        }
9779        drop(cat);
9780
9781        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
9782        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
9783        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
9784
9785        for (idx, (table_id, staged)) in staging.iter().enumerate() {
9786            commit_prepare_checkpoint(control, idx)?;
9787            let Some(schema) = table_schemas.get(table_id) else {
9788                continue;
9789            };
9790            let Some(pk) = schema.primary_key() else {
9791                continue;
9792            };
9793            match staged {
9794                Staged::Delete(row_id) => {
9795                    let handle = self.table_by_id(*table_id)?;
9796                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
9797                        continue;
9798                    };
9799                    let Some(pk_value) = row.columns.get(&pk.id) else {
9800                        continue;
9801                    };
9802                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
9803                    delete_by_key
9804                        .entry((*table_id, pk_value.encode_key()))
9805                        .or_default()
9806                        .push_back(idx);
9807                }
9808                Staged::Put(cells) => {
9809                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
9810                        put_by_key
9811                            .entry((*table_id, value.encode_key()))
9812                            .or_default()
9813                            .push_back(idx);
9814                    }
9815                }
9816                Staged::Update { row_id, .. } => {
9817                    let handle = self.table_by_id(*table_id)?;
9818                    let row = handle.lock().get(*row_id, snapshot);
9819                    if let Some(row) = row {
9820                        old_rows.insert(idx, TriggerRowImage::from_row(row));
9821                    }
9822                }
9823                Staged::Truncate => {}
9824            }
9825        }
9826
9827        let mut paired_delete = std::collections::HashSet::new();
9828        let mut paired_put = std::collections::HashSet::new();
9829        let mut events = Vec::new();
9830
9831        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
9832            commit_prepare_checkpoint(control, pair_index)?;
9833            let Some(puts) = put_by_key.get_mut(key) else {
9834                continue;
9835            };
9836            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
9837                paired_delete.insert(delete_idx);
9838                paired_put.insert(put_idx);
9839                let (table_id, _) = &staging[put_idx];
9840                let Some(table_name) = table_names.get(table_id).cloned() else {
9841                    continue;
9842                };
9843                let old = old_rows.get(&delete_idx).cloned();
9844                let new = match &staging[put_idx].1 {
9845                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
9846                    _ => None,
9847                };
9848                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
9849                events.push(WriteEvent {
9850                    table: table_name,
9851                    kind: TriggerEvent::Update,
9852                    old,
9853                    new,
9854                    changed_columns,
9855                    op_indices: vec![delete_idx, put_idx],
9856                    put_idx: Some(put_idx),
9857                    trigger_stack: Self::trigger_stack_for_indices(
9858                        trigger_stacks,
9859                        &[delete_idx, put_idx],
9860                    ),
9861                });
9862            }
9863        }
9864
9865        for (idx, (table_id, staged)) in staging.iter().enumerate() {
9866            commit_prepare_checkpoint(control, idx)?;
9867            let Some(table_name) = table_names.get(table_id).cloned() else {
9868                continue;
9869            };
9870            match staged {
9871                Staged::Put(cells) if !paired_put.contains(&idx) => {
9872                    let new = Some(TriggerRowImage::from_cells(cells));
9873                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
9874                    events.push(WriteEvent {
9875                        table: table_name,
9876                        kind: TriggerEvent::Insert,
9877                        old: None,
9878                        new,
9879                        changed_columns,
9880                        op_indices: vec![idx],
9881                        put_idx: Some(idx),
9882                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
9883                    });
9884                }
9885                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
9886                    let old = match old_rows.get(&idx).cloned() {
9887                        Some(old) => Some(old),
9888                        None => {
9889                            let handle = self.table_by_id(*table_id)?;
9890                            let row = handle.lock().get(*row_id, snapshot);
9891                            row.map(TriggerRowImage::from_row)
9892                        }
9893                    };
9894                    let Some(old) = old else {
9895                        continue;
9896                    };
9897                    let changed_columns = old.columns.keys().copied().collect();
9898                    events.push(WriteEvent {
9899                        table: table_name,
9900                        kind: TriggerEvent::Delete,
9901                        old: Some(old),
9902                        new: None,
9903                        changed_columns,
9904                        op_indices: vec![idx],
9905                        put_idx: None,
9906                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
9907                    });
9908                }
9909                Staged::Update { new_row: cells, .. } => {
9910                    let old = old_rows.get(&idx).cloned();
9911                    let new = Some(TriggerRowImage::from_cells(cells));
9912                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
9913                    events.push(WriteEvent {
9914                        table: table_name,
9915                        kind: TriggerEvent::Update,
9916                        old,
9917                        new,
9918                        changed_columns,
9919                        op_indices: vec![idx],
9920                        put_idx: Some(idx),
9921                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
9922                    });
9923                }
9924                Staged::Truncate => {}
9925                _ => {}
9926            }
9927        }
9928
9929        Ok(events)
9930    }
9931
9932    #[allow(clippy::too_many_arguments)]
9933    fn execute_trigger_program(
9934        &self,
9935        trigger: &StoredTrigger,
9936        event: &WriteEvent,
9937        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
9938        out: &mut TriggerProgramOutput<'_>,
9939        trigger_stack: &[String],
9940        config: &TriggerConfig,
9941        read_epoch: Epoch,
9942        control: Option<&crate::ExecutionControl>,
9943    ) -> Result<TriggerProgramOutcome> {
9944        let mut event = event.clone();
9945        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
9946        self.execute_trigger_steps(
9947            trigger,
9948            &trigger.program.steps,
9949            &mut event,
9950            staging,
9951            out,
9952            trigger_stack,
9953            config,
9954            &mut select_results,
9955            0,
9956            None,
9957            read_epoch,
9958            control,
9959        )
9960    }
9961
9962    #[allow(clippy::too_many_arguments)]
9963    fn execute_trigger_steps(
9964        &self,
9965        trigger: &StoredTrigger,
9966        steps: &[TriggerStep],
9967        event: &mut WriteEvent,
9968        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
9969        out: &mut TriggerProgramOutput<'_>,
9970        trigger_stack: &[String],
9971        config: &TriggerConfig,
9972        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
9973        depth: u32,
9974        selected: Option<&TriggerRowImage>,
9975        read_epoch: Epoch,
9976        control: Option<&crate::ExecutionControl>,
9977    ) -> Result<TriggerProgramOutcome> {
9978        let _ = depth;
9979        for (step_index, step) in steps.iter().enumerate() {
9980            commit_prepare_checkpoint(control, step_index)?;
9981            match step {
9982                TriggerStep::SetNew { cells } => {
9983                    if trigger.timing != TriggerTiming::Before {
9984                        return Err(MongrelError::InvalidArgument(
9985                            "SetNew trigger step is only valid in BEFORE triggers".into(),
9986                        ));
9987                    }
9988                    let put_idx = event.put_idx.ok_or_else(|| {
9989                        MongrelError::InvalidArgument(
9990                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
9991                        )
9992                    })?;
9993                    let staging = staging.as_deref_mut().ok_or_else(|| {
9994                        MongrelError::InvalidArgument(
9995                            "SetNew trigger step requires mutable trigger staging".into(),
9996                        )
9997                    })?;
9998                    let mut update_changed_columns = None;
9999                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
10000                        Some(crate::txn::Staged::Put(cells)) => cells,
10001                        Some(crate::txn::Staged::Update {
10002                            new_row,
10003                            changed_columns,
10004                            ..
10005                        }) => {
10006                            update_changed_columns = Some(changed_columns);
10007                            new_row
10008                        }
10009                        _ => {
10010                            return Err(MongrelError::InvalidArgument(
10011                                "SetNew trigger step target row is not mutable".into(),
10012                            ))
10013                        }
10014                    };
10015                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
10016                        row_cells.retain(|(id, _)| *id != column_id);
10017                        row_cells.push((column_id, value.clone()));
10018                        if let Some(changed_columns) = &mut update_changed_columns {
10019                            changed_columns.push(column_id);
10020                        }
10021                        if let Some(new) = &mut event.new {
10022                            new.columns.insert(column_id, value);
10023                        }
10024                    }
10025                    row_cells.sort_by_key(|(id, _)| *id);
10026                    if let Some(changed_columns) = update_changed_columns {
10027                        changed_columns.sort_unstable();
10028                        changed_columns.dedup();
10029                    }
10030                }
10031                TriggerStep::Insert { table, cells } => {
10032                    let cells = eval_trigger_cells(cells, event, selected)?;
10033                    if let Ok(table_id) = self.table_id(table) {
10034                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
10035                        out.added_stacks.push(trigger_stack.to_vec());
10036                    } else if self.external_table(table).is_some() {
10037                        out.added_external.push(ExternalTriggerWrite::Insert {
10038                            table: table.clone(),
10039                            cells,
10040                        });
10041                    } else {
10042                        return Err(MongrelError::NotFound(format!(
10043                            "trigger {:?} insert target {table:?} not found",
10044                            trigger.name
10045                        )));
10046                    }
10047                }
10048                TriggerStep::UpdateByPk { table, pk, cells } => {
10049                    let pk = eval_trigger_value(pk, event, selected)?;
10050                    let cells = eval_trigger_cells(cells, event, selected)?;
10051                    if self.external_table(table).is_some() {
10052                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
10053                            table: table.clone(),
10054                            pk,
10055                            cells,
10056                        });
10057                    } else {
10058                        let row_id = self
10059                            .table(table)?
10060                            .lock()
10061                            .lookup_pk(&pk.encode_key())
10062                            .ok_or_else(|| {
10063                                MongrelError::NotFound(format!(
10064                                    "trigger {:?} update target not found",
10065                                    trigger.name
10066                                ))
10067                            })?;
10068                        let handle = self.table(table)?;
10069                        let snapshot = Snapshot::at(self.epoch.visible());
10070                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
10071                            MongrelError::NotFound(format!(
10072                                "trigger {:?} update target not visible",
10073                                trigger.name
10074                            ))
10075                        })?;
10076                        let mut changed_columns = cells
10077                            .iter()
10078                            .map(|(column_id, _)| *column_id)
10079                            .collect::<Vec<_>>();
10080                        changed_columns.sort_unstable();
10081                        changed_columns.dedup();
10082                        let mut merged = old.columns;
10083                        for (column_id, value) in cells {
10084                            merged.insert(column_id, value);
10085                        }
10086                        out.added.push((
10087                            self.table_id(table)?,
10088                            crate::txn::Staged::Update {
10089                                row_id,
10090                                new_row: merged.into_iter().collect(),
10091                                changed_columns,
10092                            },
10093                        ));
10094                        out.added_stacks.push(trigger_stack.to_vec());
10095                    }
10096                }
10097                TriggerStep::DeleteByPk { table, pk } => {
10098                    let pk = eval_trigger_value(pk, event, selected)?;
10099                    if self.external_table(table).is_some() {
10100                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
10101                            table: table.clone(),
10102                            pk,
10103                        });
10104                    } else {
10105                        let row_id = self
10106                            .table(table)?
10107                            .lock()
10108                            .lookup_pk(&pk.encode_key())
10109                            .ok_or_else(|| {
10110                                MongrelError::NotFound(format!(
10111                                    "trigger {:?} delete target not found",
10112                                    trigger.name
10113                                ))
10114                            })?;
10115                        out.added
10116                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
10117                        out.added_stacks.push(trigger_stack.to_vec());
10118                    }
10119                }
10120                TriggerStep::Select {
10121                    id,
10122                    table,
10123                    conditions,
10124                } => {
10125                    let schema = self.table(table)?.lock().schema().clone();
10126                    let snapshot = Snapshot::at(read_epoch);
10127                    let handle = self.table(table)?;
10128                    let rows = match control {
10129                        Some(control) => {
10130                            handle.lock().visible_rows_controlled(snapshot, control)?
10131                        }
10132                        None => handle.lock().visible_rows(snapshot)?,
10133                    };
10134                    let mut matched = Vec::new();
10135                    for (row_index, row) in rows.into_iter().enumerate() {
10136                        commit_prepare_checkpoint(control, row_index)?;
10137                        let image = TriggerRowImage::from_row(row);
10138                        let passes = conditions
10139                            .iter()
10140                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
10141                            .collect::<Result<Vec<_>>>()?
10142                            .into_iter()
10143                            .all(|b| b);
10144                        if passes {
10145                            matched.push(image);
10146                        }
10147                    }
10148                    if let Some(pk) = schema.primary_key() {
10149                        matched.sort_by(|a, b| {
10150                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
10151                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
10152                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
10153                        });
10154                    }
10155                    select_results.insert(id.clone(), matched);
10156                }
10157                TriggerStep::Foreach { id, steps } => {
10158                    let rows = select_results.get(id).ok_or_else(|| {
10159                        MongrelError::InvalidArgument(format!(
10160                            "trigger {:?} foreach references unknown select id {id:?}",
10161                            trigger.name
10162                        ))
10163                    })?;
10164                    if rows.len() > config.max_loop_iterations as usize {
10165                        return Err(MongrelError::InvalidArgument(format!(
10166                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
10167                            trigger.name, config.max_loop_iterations
10168                        )));
10169                    }
10170                    for (row_index, row) in rows.clone().into_iter().enumerate() {
10171                        commit_prepare_checkpoint(control, row_index)?;
10172                        let result = self.execute_trigger_steps(
10173                            trigger,
10174                            steps,
10175                            event,
10176                            staging.as_deref_mut(),
10177                            out,
10178                            trigger_stack,
10179                            config,
10180                            select_results,
10181                            depth + 1,
10182                            Some(&row),
10183                            read_epoch,
10184                            control,
10185                        )?;
10186                        if result == TriggerProgramOutcome::Ignore {
10187                            return Ok(TriggerProgramOutcome::Ignore);
10188                        }
10189                    }
10190                }
10191                TriggerStep::DeleteWhere { table, conditions } => {
10192                    let schema = self.table(table)?.lock().schema().clone();
10193                    let snapshot = Snapshot::at(read_epoch);
10194                    let handle = self.table(table)?;
10195                    let rows = match control {
10196                        Some(control) => {
10197                            handle.lock().visible_rows_controlled(snapshot, control)?
10198                        }
10199                        None => handle.lock().visible_rows(snapshot)?,
10200                    };
10201                    let table_id = self.table_id(table)?;
10202                    let mut to_delete = Vec::new();
10203                    for (row_index, row) in rows.into_iter().enumerate() {
10204                        commit_prepare_checkpoint(control, row_index)?;
10205                        let image = TriggerRowImage::from_row(row.clone());
10206                        let passes = conditions
10207                            .iter()
10208                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
10209                            .collect::<Result<Vec<_>>>()?
10210                            .into_iter()
10211                            .all(|b| b);
10212                        if passes {
10213                            to_delete.push((table_id, row.row_id));
10214                        }
10215                    }
10216                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
10217                        commit_prepare_checkpoint(control, row_index)?;
10218                        out.added
10219                            .push((table_id, crate::txn::Staged::Delete(row_id)));
10220                        out.added_stacks.push(trigger_stack.to_vec());
10221                    }
10222                }
10223                TriggerStep::UpdateWhere {
10224                    table,
10225                    conditions,
10226                    cells,
10227                } => {
10228                    let schema = self.table(table)?.lock().schema().clone();
10229                    let snapshot = Snapshot::at(read_epoch);
10230                    let handle = self.table(table)?;
10231                    let rows = match control {
10232                        Some(control) => {
10233                            handle.lock().visible_rows_controlled(snapshot, control)?
10234                        }
10235                        None => handle.lock().visible_rows(snapshot)?,
10236                    };
10237                    let table_id = self.table_id(table)?;
10238                    let mut changed_columns =
10239                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
10240                    changed_columns.sort_unstable();
10241                    changed_columns.dedup();
10242                    let mut to_update = Vec::new();
10243                    for (row_index, row) in rows.into_iter().enumerate() {
10244                        commit_prepare_checkpoint(control, row_index)?;
10245                        let image = TriggerRowImage::from_row(row.clone());
10246                        let passes = conditions
10247                            .iter()
10248                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
10249                            .collect::<Result<Vec<_>>>()?
10250                            .into_iter()
10251                            .all(|b| b);
10252                        if passes {
10253                            let new_cells = cells
10254                                .iter()
10255                                .map(|cell| {
10256                                    Ok((
10257                                        cell.column_id,
10258                                        eval_trigger_value(&cell.value, event, Some(&image))?,
10259                                    ))
10260                                })
10261                                .collect::<Result<Vec<_>>>()?;
10262                            let mut merged = row.columns.clone();
10263                            for (column_id, value) in new_cells {
10264                                merged.insert(column_id, value);
10265                            }
10266                            to_update.push((table_id, row.row_id, merged));
10267                        }
10268                    }
10269                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
10270                    {
10271                        commit_prepare_checkpoint(control, row_index)?;
10272                        out.added.push((
10273                            table_id,
10274                            crate::txn::Staged::Update {
10275                                row_id,
10276                                new_row: merged.into_iter().collect(),
10277                                changed_columns: changed_columns.clone(),
10278                            },
10279                        ));
10280                        out.added_stacks.push(trigger_stack.to_vec());
10281                    }
10282                }
10283                TriggerStep::Raise { action, message } => match action {
10284                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
10285                    TriggerRaiseAction::Abort
10286                    | TriggerRaiseAction::Fail
10287                    | TriggerRaiseAction::Rollback => {
10288                        let message = eval_trigger_value(message, event, selected)?;
10289                        return Err(MongrelError::TriggerValidation(format!(
10290                            "trigger {:?} raised: {}; trigger stack: {}",
10291                            trigger.name,
10292                            trigger_message(message),
10293                            Self::format_trigger_stack(trigger_stack)
10294                        )));
10295                    }
10296                },
10297            }
10298        }
10299        Ok(TriggerProgramOutcome::Continue)
10300    }
10301
10302    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
10303        let Some(stacks) = stacks else {
10304            return Vec::new();
10305        };
10306        let mut out = Vec::new();
10307        for idx in indices {
10308            let Some(stack) = stacks.get(*idx) else {
10309                continue;
10310            };
10311            for name in stack {
10312                if !out.iter().any(|existing| existing == name) {
10313                    out.push(name.clone());
10314                }
10315            }
10316        }
10317        out
10318    }
10319
10320    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
10321        let mut out = stack.to_vec();
10322        out.push(trigger_name.to_string());
10323        out
10324    }
10325
10326    fn format_trigger_stack(stack: &[String]) -> String {
10327        if stack.is_empty() {
10328            "<root>".into()
10329        } else {
10330            stack.join(" -> ")
10331        }
10332    }
10333
10334    /// Authoritatively validate every declared constraint on the staged write
10335    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
10336    /// SET NULL actions into explicit child ops. Called from
10337    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
10338    /// violation as an `Err`, aborting the commit atomically. This is the
10339    /// server-side authority point: concurrent remote writers that each pass
10340    /// their own client-side checks still cannot both commit a violating batch.
10341    ///
10342    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
10343    /// intra-transaction dedup; concurrent-txn races are additionally caught by
10344    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
10345    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
10346    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
10347    /// RESTRICT-only (cascade-truncate is unsupported).
10348    /// S1B-003: acquire Exclusive key claims for every primary key and declared
10349    /// UNIQUE key a transaction's staged puts/updates insert, in ascending key
10350    /// order (ordered acquisition cannot cycle on its own). A concurrent
10351    /// transaction claiming the same key blocks until this one ends, turning
10352    /// the optimistic write-write conflict into a serialization point. Claims
10353    /// release with the transaction's [`TxnLockGuard`].
10354    fn acquire_unique_key_claims(
10355        &self,
10356        txn_id: u64,
10357        staging: &[(u64, crate::txn::Staged)],
10358        control: Option<&crate::ExecutionControl>,
10359    ) -> Result<()> {
10360        let catalog = self.catalog.read();
10361        let has_uniques = staging.iter().any(|(table_id, staged)| {
10362            matches!(
10363                staged,
10364                crate::txn::Staged::Put(_) | crate::txn::Staged::Update { .. }
10365            ) && catalog.tables.iter().any(|entry| {
10366                entry.table_id == *table_id
10367                    && (entry.schema.primary_key().is_some()
10368                        || !entry.schema.constraints.uniques.is_empty())
10369            })
10370        });
10371        if !has_uniques {
10372            return Ok(());
10373        }
10374        let mut claims: Vec<(u64, Vec<u8>)> = Vec::new();
10375        for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
10376            commit_prepare_checkpoint(control, staged_index)?;
10377            let cells = match staged {
10378                crate::txn::Staged::Put(cells) => cells,
10379                crate::txn::Staged::Update { new_row, .. } => new_row,
10380                _ => continue,
10381            };
10382            let Some(entry) = catalog
10383                .tables
10384                .iter()
10385                .find(|entry| entry.table_id == *table_id)
10386            else {
10387                continue;
10388            };
10389            for column in &entry.schema.columns {
10390                if !column
10391                    .flags
10392                    .contains(crate::schema::ColumnFlags::PRIMARY_KEY)
10393                {
10394                    continue;
10395                }
10396                if let Some((_, value)) = cells.iter().find(|(id, _)| *id == column.id) {
10397                    let mut key = b"pk:".to_vec();
10398                    key.extend_from_slice(&value.encode_key());
10399                    claims.push((*table_id, key));
10400                }
10401            }
10402            // Declared non-PK unique constraints claim their own namespace
10403            // (folding the constraint id into the key, per LockKey::Key's
10404            // multi-key-space rule). NULL components skip the constraint —
10405            // and the claim — per SQL semantics.
10406            let cells_map: HashMap<u16, Value> = cells.iter().cloned().collect();
10407            for uc in &entry.schema.constraints.uniques {
10408                if let Some(composite) =
10409                    crate::constraint::encode_composite_key(&uc.columns, &cells_map)
10410                {
10411                    let mut key = format!("uq{}:", uc.id).into_bytes();
10412                    key.extend_from_slice(&composite);
10413                    claims.push((*table_id, key));
10414                }
10415            }
10416        }
10417        claims.sort();
10418        claims.dedup();
10419        for (table_id, key) in claims {
10420            self.acquire_txn_lock(
10421                txn_id,
10422                crate::locks::LockKey::key(table_id, key),
10423                crate::locks::LockMode::Exclusive,
10424                control,
10425            )?;
10426        }
10427        Ok(())
10428    }
10429
10430    /// S1B-003: one FK parent-protection acquisition. `Err` propagates the
10431    /// deadlock/deadline/cancellation outcome; the test seam fires after each
10432    /// successful acquisition.
10433    fn acquire_fk_lock(
10434        &self,
10435        txn_id: u64,
10436        table_id: u64,
10437        key: &[u8],
10438        mode: crate::locks::LockMode,
10439        control: Option<&crate::ExecutionControl>,
10440    ) -> Result<()> {
10441        let mut namespaced = b"fk:".to_vec();
10442        namespaced.extend_from_slice(key);
10443        self.acquire_txn_lock(
10444            txn_id,
10445            crate::locks::LockKey::key(table_id, namespaced),
10446            mode,
10447            control,
10448        )?;
10449        // The hook is cloned out before firing: holding the slot's mutex while
10450        // a parked hook waits would block every other commit's hook call.
10451        let hook = self.fk_lock_hook.lock().clone();
10452        if let Some(hook) = hook {
10453            hook();
10454        }
10455        Ok(())
10456    }
10457
10458    fn validate_constraints(
10459        &self,
10460        txn_id: u64,
10461        staging: &mut Vec<(u64, crate::txn::Staged)>,
10462        read_epoch: Epoch,
10463        control: Option<&crate::ExecutionControl>,
10464    ) -> Result<()> {
10465        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
10466        use crate::memtable::Row;
10467        use crate::txn::Staged;
10468        use std::collections::HashSet;
10469
10470        commit_prepare_checkpoint(control, 0)?;
10471        let snapshot = Snapshot::at(read_epoch);
10472        let cat = self.catalog.read();
10473
10474        // Collect live (id, name, constraints-bearing?) for staged tables.
10475        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
10476            .tables
10477            .iter()
10478            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
10479            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
10480            .collect();
10481
10482        // Fast path: bail if no live table declares any constraints at all.
10483        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
10484        if !any_constraints {
10485            return Ok(());
10486        }
10487
10488        // Lazily-loaded visible rows per table, shared across checks.
10489        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
10490        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
10491            if let Some(r) = rows_cache.get(&table_id) {
10492                return Ok(r.clone());
10493            }
10494            let handle = self.table_by_id(table_id)?;
10495            let rows = match control {
10496                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
10497                None => handle.lock().visible_rows(snapshot)?,
10498            };
10499            rows_cache.insert(table_id, rows.clone());
10500            Ok(rows)
10501        };
10502
10503        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
10504        // carry an explicit old RowId + full new image. This makes action choice
10505        // reliable even when the referenced key itself changes; a delete+put
10506        // heuristic cannot distinguish that from unrelated operations.
10507        let mut processed_updates = HashSet::new();
10508        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
10509        let mut update_pass = 0_usize;
10510        loop {
10511            commit_prepare_checkpoint(control, update_pass)?;
10512            update_pass += 1;
10513            let updates: Vec<PendingUpdate> = staging
10514                .iter()
10515                .enumerate()
10516                .filter_map(|(index, (table_id, op))| match op {
10517                    Staged::Update {
10518                        row_id,
10519                        new_row: cells,
10520                        ..
10521                    } if !processed_updates.contains(&index) => {
10522                        Some((index, *table_id, *row_id, cells.clone()))
10523                    }
10524                    _ => None,
10525                })
10526                .collect();
10527            if updates.is_empty() {
10528                break;
10529            }
10530            let mut new_ops = Vec::new();
10531            for (update_index, (index, table_id, row_id, new_cells)) in
10532                updates.into_iter().enumerate()
10533            {
10534                commit_prepare_checkpoint(control, update_index)?;
10535                processed_updates.insert(index);
10536                let Some(tname) = live
10537                    .iter()
10538                    .find(|(id, _, _)| *id == table_id)
10539                    .map(|(_, name, _)| *name)
10540                else {
10541                    continue;
10542                };
10543                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
10544                    continue;
10545                };
10546                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
10547                for (child_id, _child_name, child_schema) in &live {
10548                    for fk in &child_schema.constraints.foreign_keys {
10549                        if fk.ref_table != tname {
10550                            continue;
10551                        }
10552                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
10553                        else {
10554                            continue;
10555                        };
10556                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
10557                            == Some(old_key.as_slice())
10558                        {
10559                            continue;
10560                        }
10561                        if fk.on_update == FkAction::Restrict {
10562                            continue;
10563                        }
10564                        // S1B-003: the referenced key is being changed, so this
10565                        // update removes it for any action — hold an Exclusive
10566                        // parent-protection lock against concurrent child
10567                        // inserts referencing the old key.
10568                        self.acquire_fk_lock(
10569                            txn_id,
10570                            table_id,
10571                            &old_key,
10572                            crate::locks::LockMode::Exclusive,
10573                            control,
10574                        )?;
10575                        let child_rows = load_rows(*child_id)?;
10576                        for (child_index, child) in child_rows.into_iter().enumerate() {
10577                            commit_prepare_checkpoint(control, child_index)?;
10578                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
10579                                != Some(old_key.as_slice())
10580                            {
10581                                continue;
10582                            }
10583                            if staging.iter().any(|(id, op)| {
10584                                *id == *child_id
10585                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
10586                            }) {
10587                                continue;
10588                            }
10589                            let mut cells: Vec<(u16, Value)> = child
10590                                .columns
10591                                .iter()
10592                                .map(|(column_id, value)| (*column_id, value.clone()))
10593                                .collect();
10594                            for (child_column, parent_column) in
10595                                fk.columns.iter().zip(&fk.ref_columns)
10596                            {
10597                                cells.retain(|(column_id, _)| column_id != child_column);
10598                                let value = match fk.on_update {
10599                                    FkAction::Cascade => {
10600                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
10601                                    }
10602                                    FkAction::SetNull => Value::Null,
10603                                    FkAction::Restrict => {
10604                                        return Err(MongrelError::Other(
10605                                            "restricted foreign-key update reached cascade preparation"
10606                                                .into(),
10607                                        ));
10608                                    }
10609                                };
10610                                cells.push((*child_column, value));
10611                            }
10612                            cells.sort_by_key(|(column_id, _)| *column_id);
10613                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
10614                                *id == *child_id
10615                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
10616                            }) {
10617                                if let Staged::Update {
10618                                    new_row: existing,
10619                                    changed_columns,
10620                                    ..
10621                                } = &mut staging[existing_index].1 {
10622                                    changed_columns.extend(fk.columns.iter().copied());
10623                                    changed_columns.sort_unstable();
10624                                    changed_columns.dedup();
10625                                    if *existing != cells {
10626                                        *existing = cells;
10627                                        processed_updates.remove(&existing_index);
10628                                    }
10629                                }
10630                            } else {
10631                                new_ops.push((
10632                                    *child_id,
10633                                    Staged::Update {
10634                                        row_id: child.row_id,
10635                                        new_row: cells,
10636                                        changed_columns: fk.columns.clone(),
10637                                    },
10638                                ));
10639                            }
10640                        }
10641                    }
10642                }
10643            }
10644            staging.extend(new_ops);
10645        }
10646
10647        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
10648        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
10649        // enforced as a violation in Phase B. `cascaded` records every delete
10650        // we have already expanded so a self-referential CASCADE FK cannot loop.
10651        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
10652        let mut cascade_pass = 0_usize;
10653        loop {
10654            commit_prepare_checkpoint(control, cascade_pass)?;
10655            cascade_pass += 1;
10656            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
10657            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
10658                .iter()
10659                .filter_map(|(t, op)| match op {
10660                    Staged::Delete(rid) => Some((*t, *rid)),
10661                    _ => None,
10662                })
10663                .collect();
10664            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
10665                commit_prepare_checkpoint(control, delete_index)?;
10666                if !cascaded.insert((table_id, rid.0)) {
10667                    continue;
10668                }
10669                let Some(tname) = live
10670                    .iter()
10671                    .find(|(t, _, _)| *t == table_id)
10672                    .map(|(_, n, _)| *n)
10673                else {
10674                    continue;
10675                };
10676                let parent_handle = self.table_by_id(table_id)?;
10677                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
10678                    continue;
10679                };
10680                for (child_id, _child_name, child_schema) in &live {
10681                    for fk in &child_schema.constraints.foreign_keys {
10682                        if fk.ref_table != tname {
10683                            continue;
10684                        }
10685                        let Some(parent_key) =
10686                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
10687                        else {
10688                            continue;
10689                        };
10690                        // Suppress ON DELETE cascade/set-null when this "delete"
10691                        // is actually half of an UPDATE encoded as Delete(old)+
10692                        // Put(new): if a staged Put in the SAME table still
10693                        // provides the referenced parent key, the parent still
10694                        // exists (its non-key columns changed) and the children
10695                        // must be left alone. A genuine delete, or an update
10696                        // that CHANGES the referenced key, has no preserving Put
10697                        // → cascade fires as before.
10698                        let key_preserved = staging.iter().any(|(t, op)| {
10699                            if *t != table_id {
10700                                return false;
10701                            }
10702                            let Staged::Put(cells) = op else {
10703                                return false;
10704                            };
10705                            let map: HashMap<u16, crate::memtable::Value> =
10706                                cells.iter().cloned().collect();
10707                            encode_composite_key(&fk.ref_columns, &map).as_deref()
10708                                == Some(parent_key.as_slice())
10709                        });
10710                        if key_preserved {
10711                            continue;
10712                        }
10713                        // S1B-003: the referenced parent key is genuinely being
10714                        // removed (delete, or the delete half of a key-changing
10715                        // update), for every FK action — hold an Exclusive
10716                        // parent-protection lock against concurrent child
10717                        // inserts referencing it. RESTRICT fks are enforced in
10718                        // Phase B; the claim covers them too.
10719                        self.acquire_fk_lock(
10720                            txn_id,
10721                            table_id,
10722                            &parent_key,
10723                            crate::locks::LockMode::Exclusive,
10724                            control,
10725                        )?;
10726                        match fk.on_delete {
10727                            FkAction::Restrict => continue,
10728                            FkAction::Cascade => {
10729                                let child_rows = load_rows(*child_id)?;
10730                                for (child_index, cr) in child_rows.iter().enumerate() {
10731                                    commit_prepare_checkpoint(control, child_index)?;
10732                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
10733                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
10734                                            == Some(parent_key.as_slice())
10735                                    {
10736                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
10737                                    }
10738                                }
10739                            }
10740                            FkAction::SetNull => {
10741                                let child_rows = load_rows(*child_id)?;
10742                                for (child_index, cr) in child_rows.iter().enumerate() {
10743                                    commit_prepare_checkpoint(control, child_index)?;
10744                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
10745                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
10746                                            == Some(parent_key.as_slice())
10747                                    {
10748                                        // Re-emit the child row with the FK
10749                                        // columns set to NULL (delete + put).
10750                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
10751                                            .columns
10752                                            .iter()
10753                                            .map(|(k, v)| (*k, v.clone()))
10754                                            .collect();
10755                                        for cid in &fk.columns {
10756                                            cells.retain(|(k, _)| k != cid);
10757                                            cells.push((*cid, crate::memtable::Value::Null));
10758                                        }
10759                                        new_ops.push((
10760                                            *child_id,
10761                                            Staged::Update {
10762                                                row_id: cr.row_id,
10763                                                new_row: cells,
10764                                                changed_columns: fk.columns.clone(),
10765                                            },
10766                                        ));
10767                                    }
10768                                }
10769                            }
10770                        }
10771                    }
10772                }
10773            }
10774            if new_ops.is_empty() {
10775                break;
10776            }
10777            staging.extend(new_ops);
10778        }
10779
10780        // Rows staged for deletion in THIS transaction (now including cascaded
10781        // deletes). Used to exclude the old version of an updated row from
10782        // unique-existence scans.
10783        let staged_deletes: HashSet<(u64, u64)> = staging
10784            .iter()
10785            .filter_map(|(t, op)| match op {
10786                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
10787                _ => None,
10788            })
10789            .collect();
10790
10791        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
10792        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
10793
10794        // ── Phase B: validate the fully-expanded staging set.
10795        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
10796            commit_prepare_checkpoint(control, operation_index)?;
10797            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
10798            else {
10799                continue;
10800            };
10801            let cells_map: HashMap<u16, crate::memtable::Value>;
10802            match op {
10803                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
10804                    cells_map = cells.iter().cloned().collect();
10805
10806                    // CHECK constraints.
10807                    if !schema.constraints.checks.is_empty() {
10808                        validate_checks(&schema.constraints.checks, &cells_map)?;
10809                    }
10810
10811                    // UNIQUE (non-PK) constraints.
10812                    for uc in &schema.constraints.uniques {
10813                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
10814                            continue; // NULL in a constrained column → skip (SQL).
10815                        };
10816                        let marker = (*table_id, uc.id, key.clone());
10817                        if !seen_unique.insert(marker) {
10818                            return Err(MongrelError::Conflict(format!(
10819                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
10820                                uc.name
10821                            )));
10822                        }
10823                        let rows = load_rows(*table_id)?;
10824                        for (row_index, r) in rows.iter().enumerate() {
10825                            commit_prepare_checkpoint(control, row_index)?;
10826                            // Skip rows this same transaction is deleting (the
10827                            // old version of an updated/cascade-deleted row).
10828                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
10829                                continue;
10830                            }
10831                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
10832                                if theirs == key {
10833                                    return Err(MongrelError::Conflict(format!(
10834                                        "UNIQUE constraint '{}' on table '{tname}' violated",
10835                                        uc.name
10836                                    )));
10837                                }
10838                            }
10839                        }
10840                    }
10841
10842                    // FK insert-side: parent must exist.
10843                    for fk in &schema.constraints.foreign_keys {
10844                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
10845                            continue; // NULL FK component → not checked (SQL).
10846                        };
10847                        let Some(parent_id) = cat
10848                            .tables
10849                            .iter()
10850                            .find(|t| t.name == fk.ref_table)
10851                            .map(|t| t.table_id)
10852                        else {
10853                            return Err(MongrelError::InvalidArgument(format!(
10854                                "FOREIGN KEY '{}' references unknown table '{}'",
10855                                fk.name, fk.ref_table
10856                            )));
10857                        };
10858                        // S1B-003: hold a Shared parent-protection lock on the
10859                        // referenced key while checking existence, so a
10860                        // concurrent parent delete or key-changing update
10861                        // serializes against this insert.
10862                        self.acquire_fk_lock(
10863                            txn_id,
10864                            parent_id,
10865                            &child_key,
10866                            crate::locks::LockMode::Shared,
10867                            control,
10868                        )?;
10869                        let parent_rows = load_rows(parent_id)?;
10870                        let mut found = false;
10871                        for (row_index, r) in parent_rows.iter().enumerate() {
10872                            commit_prepare_checkpoint(control, row_index)?;
10873                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
10874                                continue;
10875                            }
10876                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
10877                                if pkey == child_key {
10878                                    found = true;
10879                                    break;
10880                                }
10881                            }
10882                        }
10883                        // Final-write-set FK validation: a parent inserted in
10884                        // THIS transaction also satisfies the FK. This enables
10885                        // atomic parent+child batches and cyclical/mutual FK
10886                        // inserts within a single transaction — the child sees
10887                        // the staged parent put even though it is not committed
10888                        // yet.
10889                        if !found {
10890                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
10891                                commit_prepare_checkpoint(control, staged_index)?;
10892                                if *st_table != parent_id {
10893                                    continue;
10894                                }
10895                                if let Staged::Put(pcells)
10896                                | Staged::Update {
10897                                    new_row: pcells, ..
10898                                } = st_op
10899                                {
10900                                    let pmap: HashMap<u16, crate::memtable::Value> =
10901                                        pcells.iter().cloned().collect();
10902                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
10903                                    {
10904                                        if pkey == child_key {
10905                                            found = true;
10906                                            break;
10907                                        }
10908                                    }
10909                                }
10910                            }
10911                        }
10912                        if !found {
10913                            return Err(MongrelError::Conflict(format!(
10914                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
10915                                fk.name, fk.ref_table
10916                            )));
10917                        }
10918                    }
10919
10920                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
10921                    // expanded in Phase A; here the final child write set is
10922                    // known, so a child explicitly moved/deleted by this same
10923                    // transaction does not cause a false violation.
10924                    if let Staged::Update { row_id, .. } = op {
10925                        let parent_handle = self.table_by_id(*table_id)?;
10926                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
10927                            continue;
10928                        };
10929                        for (child_id, child_name, child_schema) in &live {
10930                            for fk in &child_schema.constraints.foreign_keys {
10931                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
10932                                    continue;
10933                                }
10934                                let Some(old_key) =
10935                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
10936                                else {
10937                                    continue;
10938                                };
10939                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
10940                                    == Some(old_key.as_slice())
10941                                {
10942                                    continue;
10943                                }
10944                                // S1B-003: the referenced key is being changed —
10945                                // hold an Exclusive parent-protection lock
10946                                // against concurrent child inserts referencing
10947                                // the old key.
10948                                self.acquire_fk_lock(
10949                                    txn_id,
10950                                    *table_id,
10951                                    &old_key,
10952                                    crate::locks::LockMode::Exclusive,
10953                                    control,
10954                                )?;
10955                                for (child_index, child) in
10956                                    load_rows(*child_id)?.into_iter().enumerate()
10957                                {
10958                                    commit_prepare_checkpoint(control, child_index)?;
10959                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
10960                                        != Some(old_key.as_slice())
10961                                    {
10962                                        continue;
10963                                    }
10964                                    let replacement = staging.iter().find_map(|(id, op)| {
10965                                        if *id != *child_id {
10966                                            return None;
10967                                        }
10968                                        match op {
10969                                            Staged::Delete(id) if *id == child.row_id => Some(None),
10970                                            Staged::Update {
10971                                                row_id,
10972                                                new_row: cells,
10973                                                ..
10974                                            } if *row_id == child.row_id => {
10975                                                let map: HashMap<u16, Value> =
10976                                                    cells.iter().cloned().collect();
10977                                                Some(encode_composite_key(&fk.columns, &map))
10978                                            }
10979                                            _ => None,
10980                                        }
10981                                    });
10982                                    if replacement.is_some_and(|key| {
10983                                        key.as_deref() != Some(old_key.as_slice())
10984                                    }) {
10985                                        continue;
10986                                    }
10987                                    return Err(MongrelError::Conflict(format!(
10988                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
10989                                        fk.name
10990                                    )));
10991                                }
10992                            }
10993                        }
10994                    }
10995                }
10996                Staged::Delete(rid) => {
10997                    // FK ON DELETE RESTRICT: a child row (whose FK action is
10998                    // RESTRICT) referencing this parent blocks the delete.
10999                    // CASCADE/SET NULL children were expanded in Phase A.
11000                    let parent_handle = self.table_by_id(*table_id)?;
11001                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
11002                        continue;
11003                    };
11004                    for (child_id, child_name, child_schema) in &live {
11005                        for fk in &child_schema.constraints.foreign_keys {
11006                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
11007                                continue;
11008                            }
11009                            let Some(parent_key) =
11010                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
11011                            else {
11012                                continue;
11013                            };
11014                            let child_rows = load_rows(*child_id)?;
11015                            for (row_index, r) in child_rows.iter().enumerate() {
11016                                commit_prepare_checkpoint(control, row_index)?;
11017                                // A child already being deleted by this txn
11018                                // (cascade/inline) is not a restrict violation.
11019                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
11020                                    continue;
11021                                }
11022                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
11023                                    if ck == parent_key {
11024                                        return Err(MongrelError::Conflict(format!(
11025                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
11026                                            fk.name
11027                                        )));
11028                                    }
11029                                }
11030                            }
11031                        }
11032                    }
11033                }
11034                Staged::Truncate => {
11035                    // Truncate is RESTRICT-only: reject if any child references
11036                    // this table (any FK action), since cascade-truncate is
11037                    // unsupported.
11038                    for (child_id, child_name, child_schema) in &live {
11039                        for fk in &child_schema.constraints.foreign_keys {
11040                            if fk.ref_table != tname {
11041                                continue;
11042                            }
11043                            let child_rows = load_rows(*child_id)?;
11044                            if child_rows
11045                                .iter()
11046                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
11047                            {
11048                                return Err(MongrelError::Conflict(format!(
11049                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
11050                                    fk.name
11051                                )));
11052                            }
11053                        }
11054                    }
11055                }
11056            }
11057        }
11058        Ok(())
11059    }
11060
11061    fn validate_write_permissions(
11062        &self,
11063        staging: &[(u64, crate::txn::Staged)],
11064        principal: Option<&crate::auth::Principal>,
11065        control: Option<&crate::ExecutionControl>,
11066    ) -> Result<()> {
11067        commit_prepare_checkpoint(control, 0)?;
11068        if principal.is_none() && !self.auth_state.require_auth() {
11069            return Ok(());
11070        }
11071        let principal = principal.ok_or(MongrelError::AuthRequired)?;
11072        let needs = summarize_write_permissions(staging);
11073        let catalog = self.catalog.read();
11074
11075        if needs.values().any(|need| need.truncate) {
11076            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
11077        }
11078        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
11079            commit_prepare_checkpoint(control, need_index)?;
11080            let entry = catalog
11081                .tables
11082                .iter()
11083                .find(|entry| {
11084                    entry.table_id == table_id
11085                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
11086                })
11087                .ok_or_else(|| {
11088                    MongrelError::NotFound(format!(
11089                        "live table {table_id} not found during write validation"
11090                    ))
11091                })?;
11092            if matches!(entry.state, TableState::Building { .. }) {
11093                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
11094                continue;
11095            }
11096            if need.insert {
11097                Self::require_columns_for_principal(
11098                    &entry.name,
11099                    &entry.schema,
11100                    crate::auth::ColumnOperation::Insert,
11101                    &need.insert_columns,
11102                    principal,
11103                )?;
11104            }
11105            if need.update {
11106                Self::require_columns_for_principal(
11107                    &entry.name,
11108                    &entry.schema,
11109                    crate::auth::ColumnOperation::Update,
11110                    &need.update_columns,
11111                    principal,
11112                )?;
11113            }
11114            if need.delete {
11115                self.require_for(
11116                    Some(principal),
11117                    &crate::auth::Permission::Delete {
11118                        table: entry.name.clone(),
11119                    },
11120                )?;
11121            }
11122        }
11123        Ok(())
11124    }
11125
11126    fn validate_security_writes(
11127        &self,
11128        staging: &[(u64, crate::txn::Staged)],
11129        read_epoch: Epoch,
11130        explicit_principal: Option<&crate::auth::Principal>,
11131        control: Option<&crate::ExecutionControl>,
11132    ) -> Result<()> {
11133        commit_prepare_checkpoint(control, 0)?;
11134        use crate::security::PolicyCommand;
11135        use crate::txn::Staged;
11136
11137        let catalog = self.catalog.read();
11138        if catalog.security.rls_tables.is_empty() {
11139            return Ok(());
11140        }
11141        let security = catalog.security.clone();
11142        let table_names = catalog
11143            .tables
11144            .iter()
11145            .filter(|entry| matches!(entry.state, TableState::Live))
11146            .map(|entry| (entry.table_id, entry.name.clone()))
11147            .collect::<HashMap<_, _>>();
11148        drop(catalog);
11149        if !staging.iter().any(|(table_id, _)| {
11150            table_names
11151                .get(table_id)
11152                .is_some_and(|table| security.rls_enabled(table))
11153        }) {
11154            return Ok(());
11155        }
11156        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
11157
11158        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
11159            commit_prepare_checkpoint(control, operation_index)?;
11160            let Some(table) = table_names.get(table_id) else {
11161                continue;
11162            };
11163            if !security.rls_enabled(table) || principal.is_admin {
11164                continue;
11165            }
11166            let denied = |command| MongrelError::PermissionDenied {
11167                required: match command {
11168                    PolicyCommand::Insert => crate::auth::Permission::Insert {
11169                        table: table.clone(),
11170                    },
11171                    PolicyCommand::Update => crate::auth::Permission::Update {
11172                        table: table.clone(),
11173                    },
11174                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
11175                        crate::auth::Permission::Delete {
11176                            table: table.clone(),
11177                        }
11178                    }
11179                },
11180                principal: principal.username.clone(),
11181            };
11182            match operation {
11183                Staged::Put(cells) => {
11184                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
11185                    row.columns.extend(cells.iter().cloned());
11186                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
11187                        return Err(denied(PolicyCommand::Insert));
11188                    }
11189                }
11190                Staged::Update {
11191                    row_id,
11192                    new_row: cells,
11193                    ..
11194                } => {
11195                    let old = self
11196                        .table_by_id(*table_id)?
11197                        .lock()
11198                        .get(*row_id, Snapshot::at(read_epoch))
11199                        .ok_or_else(|| {
11200                            MongrelError::NotFound(format!("row {} not found", row_id.0))
11201                        })?;
11202                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
11203                        return Err(denied(PolicyCommand::Update));
11204                    }
11205                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
11206                    new.columns.extend(cells.iter().cloned());
11207                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
11208                        return Err(denied(PolicyCommand::Update));
11209                    }
11210                }
11211                Staged::Delete(row_id) => {
11212                    let old = self
11213                        .table_by_id(*table_id)?
11214                        .lock()
11215                        .get(*row_id, Snapshot::at(read_epoch))
11216                        .ok_or_else(|| {
11217                            MongrelError::NotFound(format!("row {} not found", row_id.0))
11218                        })?;
11219                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
11220                        return Err(denied(PolicyCommand::Delete));
11221                    }
11222                }
11223                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
11224            }
11225        }
11226        Ok(())
11227    }
11228
11229    /// Seal a transaction (spec §9.3):
11230    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
11231    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
11232    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
11233    ///    group-sync, record conflict keys.
11234    /// 3. Publish — apply to tables, advance visible in-order.
11235    #[allow(clippy::too_many_arguments)]
11236    pub(crate) fn commit_transaction_with_external_states(
11237        &self,
11238        txn_id: u64,
11239        read_epoch: Epoch,
11240        staging: Vec<(u64, crate::txn::Staged)>,
11241        external_states: Vec<(String, Vec<u8>)>,
11242        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
11243        security_principal: Option<crate::auth::Principal>,
11244        principal_catalog_bound: bool,
11245        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
11246        context: crate::txn::TxnCommitContext,
11247    ) -> Result<(Epoch, Vec<RowId>)> {
11248        self.commit_transaction_with_external_states_inner(
11249            txn_id,
11250            read_epoch,
11251            staging,
11252            external_states,
11253            materialized_view_updates,
11254            security_principal,
11255            principal_catalog_bound,
11256            external_trigger_bridge,
11257            context,
11258            None,
11259            None,
11260        )
11261    }
11262
11263    #[allow(clippy::too_many_arguments)]
11264    pub(crate) fn commit_transaction_with_external_states_controlled(
11265        &self,
11266        txn_id: u64,
11267        read_epoch: Epoch,
11268        staging: Vec<(u64, crate::txn::Staged)>,
11269        external_states: Vec<(String, Vec<u8>)>,
11270        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
11271        security_principal: Option<crate::auth::Principal>,
11272        principal_catalog_bound: bool,
11273        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
11274        context: crate::txn::TxnCommitContext,
11275        control: &crate::ExecutionControl,
11276        before_commit: &mut dyn FnMut() -> Result<()>,
11277    ) -> Result<(Epoch, Vec<RowId>)> {
11278        self.commit_transaction_with_external_states_inner(
11279            txn_id,
11280            read_epoch,
11281            staging,
11282            external_states,
11283            materialized_view_updates,
11284            security_principal,
11285            principal_catalog_bound,
11286            external_trigger_bridge,
11287            context,
11288            Some(control),
11289            Some(before_commit),
11290        )
11291    }
11292
11293    #[allow(clippy::too_many_arguments)]
11294    fn commit_transaction_with_external_states_inner(
11295        &self,
11296        txn_id: u64,
11297        read_epoch: Epoch,
11298        mut staging: Vec<(u64, crate::txn::Staged)>,
11299        external_states: Vec<(String, Vec<u8>)>,
11300        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
11301        mut security_principal: Option<crate::auth::Principal>,
11302        principal_catalog_bound: bool,
11303        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
11304        context: crate::txn::TxnCommitContext,
11305        control: Option<&crate::ExecutionControl>,
11306        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11307    ) -> Result<(Epoch, Vec<RowId>)> {
11308        use crate::memtable::Row;
11309        use crate::txn::{Staged, StagedOp, WriteKey};
11310        use crate::wal::Op;
11311        use std::collections::hash_map::DefaultHasher;
11312        use std::hash::{Hash, Hasher};
11313        use std::sync::atomic::Ordering;
11314
11315        if txn_id == crate::wal::SYSTEM_TXN_ID {
11316            return Err(MongrelError::Full(
11317                "per-open transaction id namespace exhausted; reopen the database".into(),
11318            ));
11319        }
11320        if self.read_only {
11321            return Err(MongrelError::ReadOnlyReplica);
11322        }
11323        commit_prepare_checkpoint(control, 0)?;
11324        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
11325        self.refresh_security_catalog_if_stale(observed_security_version)?;
11326        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
11327        if self.auth_state.require_auth() && security_principal.is_none() {
11328            return Err(MongrelError::AuthRequired);
11329        }
11330        {
11331            let catalog = self.catalog.read();
11332            if catalog.require_auth
11333                || principal_catalog_bound
11334                || security_principal
11335                    .as_ref()
11336                    .is_some_and(|principal| principal.user_id != 0)
11337            {
11338                let principal = security_principal
11339                    .as_ref()
11340                    .ok_or(MongrelError::AuthRequired)?;
11341                security_principal =
11342                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
11343                if security_principal.is_none() {
11344                    return Err(MongrelError::AuthRequired);
11345                }
11346            }
11347        }
11348        let _replication_guard = self.replication_barrier.read();
11349        if self.poisoned.load(Ordering::Relaxed) {
11350            return Err(MongrelError::Other(
11351                "database poisoned by fsync error".into(),
11352            ));
11353        }
11354        // S1A-004: admit the commit as one core operation (rejects once the
11355        // core is draining, closing, closed, or lifecycle-poisoned; the legacy
11356        // fsync-poison error above still wins for a WAL-poisoned core).
11357        let _operation = self.admit_operation()?;
11358
11359        // ── S1B-003: user transactions hold the schema barrier Shared for the
11360        // whole commit so DDL (Exclusive) excludes concurrent DML. Internal
11361        // commits (catalog backfills, external-table state — `context.state`
11362        // is `None`) skip it: they run under their DDL operation's own
11363        // Exclusive hold, and acquiring a second, differently-keyed hold here
11364        // would self-deadlock. The guard releases every lock this commit
11365        // acquired — schema barrier, unique claims, sequence barriers, FK
11366        // holds — on every exit path (S1B-004 step 12).
11367        if context.state.is_some() {
11368            self.acquire_txn_lock(
11369                txn_id,
11370                crate::locks::LockKey::schema_barrier(),
11371                crate::locks::LockMode::Shared,
11372                control,
11373            )?;
11374        }
11375        let _txn_lock_guard = TxnLockGuard {
11376            locks: &self.lock_manager,
11377            txn_id,
11378        };
11379
11380        // ── S1B-005: idempotency check BEFORE the proposal. A repeated key
11381        // with an identical request replays the original receipt without
11382        // re-executing; a different request under the same key conflicts; a
11383        // new key is reserved durably before any WAL record can become
11384        // durable, so a crash mid-commit fails closed on retry.
11385        let idempotency_request = match &context.idempotency {
11386            Some(request) => match self.idempotency.check_and_reserve(request)? {
11387                crate::txn::IdempotencyCheck::Replay(receipt) => {
11388                    let epoch = Epoch(receipt.log_position.index);
11389                    if let Some(state) = &context.state {
11390                        state.committed(receipt);
11391                    }
11392                    return Ok((epoch, Vec::new()));
11393                }
11394                crate::txn::IdempotencyCheck::Reserved => Some(request.clone()),
11395            },
11396            None => None,
11397        };
11398        // Release the reservation on every pre-receipt failure path.
11399        let mut idempotency_guard = idempotency_request.as_ref().map(|request| {
11400            crate::txn::IdempotencyReservationGuard::new(&self.idempotency, request.clone())
11401        });
11402        let mut external_states = dedup_external_states(external_states);
11403        if !external_states.is_empty() {
11404            let cat = self.catalog.read();
11405            for (name, _) in &external_states {
11406                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
11407                    return Err(MongrelError::NotFound(format!(
11408                        "external table {name:?} not found"
11409                    )));
11410                }
11411            }
11412        }
11413        let prepared_materialized_views = {
11414            let mut deduplicated = HashMap::new();
11415            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
11416            {
11417                commit_prepare_checkpoint(control, definition_index)?;
11418                if definition.name.is_empty() || definition.query.trim().is_empty() {
11419                    return Err(MongrelError::InvalidArgument(
11420                        "materialized view name and query must not be empty".into(),
11421                    ));
11422                }
11423                deduplicated.insert(definition.name.clone(), definition);
11424            }
11425            let catalog = self.catalog.read();
11426            let mut prepared = Vec::with_capacity(deduplicated.len());
11427            for (definition_index, definition) in deduplicated.into_values().enumerate() {
11428                commit_prepare_checkpoint(control, definition_index)?;
11429                let table_id = catalog
11430                    .live(&definition.name)
11431                    .ok_or_else(|| {
11432                        MongrelError::NotFound(format!(
11433                            "materialized view table {:?} not found",
11434                            definition.name
11435                        ))
11436                    })?
11437                    .table_id;
11438                prepared.push((table_id, definition));
11439            }
11440            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
11441            prepared
11442        };
11443
11444        // ── 1. Prepare: fill generated values, expand triggers, validate, then
11445        // derive write keys from the final atomic write set.
11446        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
11447        self.expand_table_triggers(
11448            txn_id,
11449            &mut staging,
11450            read_epoch,
11451            external_trigger_bridge,
11452            &mut external_states,
11453            control,
11454        )?;
11455        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
11456        external_states = dedup_external_states(external_states);
11457        let expected_external_generations = {
11458            let catalog = self.catalog.read();
11459            let mut generations = HashMap::with_capacity(external_states.len());
11460            for (name, _) in &external_states {
11461                let entry = catalog
11462                    .external_tables
11463                    .iter()
11464                    .find(|entry| entry.name == *name)
11465                    .ok_or_else(|| {
11466                        MongrelError::NotFound(format!("external table {name:?} not found"))
11467                    })?;
11468                generations.insert(name.clone(), entry.created_epoch);
11469            }
11470            generations
11471        };
11472
11473        // S1B-003: claim every unique key this transaction inserts (primary
11474        // keys and declared UNIQUE constraints) in Exclusive mode BEFORE
11475        // validation reads. Concurrent inserts of the same key serialize on
11476        // the claim instead of racing to a write-write conflict; the
11477        // first-committer-wins index below remains the safety net.
11478        self.acquire_unique_key_claims(txn_id, &staging, control)?;
11479        // Validate declarative constraints (unique / FK / check) under the read
11480        // snapshot, outside the WAL mutex. Trigger-produced writes are included
11481        // here, so the batch either satisfies every declared constraint or is
11482        // rejected atomically.
11483        self.validate_constraints(txn_id, &mut staging, read_epoch, control)?;
11484        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
11485        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
11486        let mut normalized = Vec::with_capacity(staging.len() * 2);
11487        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
11488            commit_prepare_checkpoint(control, staged_index)?;
11489            match op {
11490                crate::txn::Staged::Update {
11491                    row_id,
11492                    new_row: cells,
11493                    ..
11494                } => {
11495                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
11496                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
11497                }
11498                op => normalized.push((table_id, op)),
11499            }
11500        }
11501        staging = normalized;
11502        let has_changes = !staging.is_empty()
11503            || !external_states.is_empty()
11504            || !prepared_materialized_views.is_empty();
11505        let truncated_tables: HashSet<u64> = staging
11506            .iter()
11507            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
11508            .collect();
11509
11510        let write_keys = {
11511            let cat = self.catalog.read();
11512            let mut keys: Vec<WriteKey> = Vec::new();
11513            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11514                commit_prepare_checkpoint(control, staged_index)?;
11515                match staged {
11516                    Staged::Put(cells) => {
11517                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
11518                            for col in &entry.schema.columns {
11519                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
11520                                    if let Some((_, val)) =
11521                                        cells.iter().find(|(id, _)| *id == col.id)
11522                                    {
11523                                        let mut h = DefaultHasher::new();
11524                                        val.encode_key().hash(&mut h);
11525                                        keys.push(WriteKey::Unique {
11526                                            table_id: *table_id,
11527                                            index_id: 0,
11528                                            key_hash: h.finish(),
11529                                        });
11530                                    }
11531                                }
11532                            }
11533                            // Declared non-PK unique constraints register a
11534                            // `WriteKey::Unique` (namespace-separated from the
11535                            // PK's index_id==0 by setting the high bit) so two
11536                            // concurrent transactions inserting the same key
11537                            // cannot both commit. Rows with any NULL constrained
11538                            // column are skipped (SQL semantics).
11539                            for uc in &entry.schema.constraints.uniques {
11540                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
11541                                    &uc.columns,
11542                                    &cells.iter().cloned().collect(),
11543                                ) {
11544                                    let mut h = DefaultHasher::new();
11545                                    key_bytes.hash(&mut h);
11546                                    keys.push(WriteKey::Unique {
11547                                        table_id: *table_id,
11548                                        index_id: uc.id | 0x8000,
11549                                        key_hash: h.finish(),
11550                                    });
11551                                }
11552                            }
11553                        }
11554                    }
11555                    Staged::Delete(rid) => keys.push(WriteKey::Row {
11556                        table_id: *table_id,
11557                        row_id: rid.0,
11558                    }),
11559                    Staged::Truncate => keys.push(WriteKey::Table {
11560                        table_id: *table_id,
11561                    }),
11562                    Staged::Update { .. } => {
11563                        return Err(MongrelError::Other(
11564                            "transaction contains an unnormalized update during preparation".into(),
11565                        ));
11566                    }
11567                }
11568            }
11569            for (external_index, (name, _)) in external_states.iter().enumerate() {
11570                commit_prepare_checkpoint(control, external_index)?;
11571                let mut h = DefaultHasher::new();
11572                name.hash(&mut h);
11573                keys.push(WriteKey::Unique {
11574                    table_id: EXTERNAL_TABLE_ID,
11575                    index_id: 0,
11576                    key_hash: h.finish(),
11577                });
11578            }
11579            keys
11580        };
11581
11582        // Opportunistic pruning.
11583        let min_active = self.active_txns.min_read_epoch();
11584        if min_active < u64::MAX {
11585            self.conflicts.prune_below(Epoch(min_active));
11586        }
11587
11588        // S1B-002 (Serializable): SSI certification keys — every tracked point
11589        // read as a row key, every tracked predicate/range read as a table
11590        // key. A concurrent commit covering any of them after this
11591        // transaction's read epoch is an rw-antidependency dangerous
11592        // structure; certification aborts rather than allowing a
11593        // non-serializable interleaving.
11594        let ssi_keys = match context.isolation.canonical() {
11595            crate::txn::IsolationLevel::Serializable => {
11596                crate::txn::ssi_validation_keys(&context.read_set, &context.predicate_set)
11597            }
11598            _ => Vec::new(),
11599        };
11600
11601        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
11602        // §8.5, review fix #17). Snapshot the conflict-index version so the
11603        // sequencer only re-checks if new commits arrived in the interim.
11604        if self.conflicts.conflicts(&write_keys, read_epoch) {
11605            return Err(MongrelError::Conflict(
11606                "write-write conflict (pre-validate, first-committer-wins)".into(),
11607            ));
11608        }
11609        if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
11610            return Err(MongrelError::SerializationFailure {
11611                message: "a concurrent commit invalidated this transaction's reads (pre-validate)"
11612                    .into(),
11613            });
11614        }
11615        let pre_validate_version = self.conflicts.version();
11616
11617        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
11618        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
11619        // streamed as Put records; they are linked at publish time.
11620        let mut spilled: Vec<SpilledRun> = Vec::new();
11621        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
11622        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
11623        // as the spill runs are live (registered on first spill, dropped at the
11624        // end of this function on commit/abort/error).
11625        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
11626        {
11627            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
11628            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
11629            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11630                commit_prepare_checkpoint(control, staged_index)?;
11631                if let Staged::Put(cells) = staged {
11632                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
11633                        bytes.saturating_add(value.estimated_bytes())
11634                    });
11635                    let table_bytes = table_bytes.entry(*table_id).or_default();
11636                    *table_bytes = table_bytes.saturating_add(bytes);
11637                    put_indexes.entry(*table_id).or_default().push(staged_index);
11638                }
11639            }
11640            let tables = self.tables.read();
11641            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
11642                commit_prepare_checkpoint(control, table_index)?;
11643                if bytes
11644                    <= self
11645                        .spill_threshold
11646                        .load(std::sync::atomic::Ordering::Relaxed)
11647                {
11648                    continue;
11649                }
11650                let Some(handle) = tables.get(&table_id) else {
11651                    continue;
11652                };
11653                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
11654                let mut t = handle.lock();
11655                let tdir = t.table_dir().to_path_buf();
11656                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
11657                std::fs::create_dir_all(&txn_dir)?;
11658                let run_id = t.alloc_run_id()? as u128;
11659                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
11660                let final_path = t.run_path(run_id as u64);
11661
11662                let mut rows: Vec<Row> = Vec::new();
11663                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
11664                    commit_prepare_checkpoint(control, put_index)?;
11665                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
11666                        return Err(MongrelError::Other(
11667                            "transaction put index no longer references a put".into(),
11668                        ));
11669                    };
11670                    t.validate_cells_not_null(cells)?;
11671                    let row_id = t.alloc_row_id()?;
11672                    let mut row = Row::new(row_id, Epoch(0));
11673                    row.columns.extend(std::mem::take(cells));
11674                    rows.push(row);
11675                }
11676                let schema = t.schema_ref().clone();
11677                let kek = t.kek_ref().cloned();
11678                let specs = t.indexable_column_specs();
11679                drop(t);
11680
11681                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
11682                    .uniform_epoch(true);
11683                if let Some(ref kek) = kek {
11684                    writer = writer.with_encryption(kek.as_ref(), specs);
11685                }
11686                commit_prepare_checkpoint(control, 0)?;
11687                let header = writer.write(&pending_path, &rows)?;
11688                commit_prepare_checkpoint(control, 0)?;
11689                let row_count = header.row_count;
11690                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
11691                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
11692
11693                spilled.push(SpilledRun {
11694                    table_id,
11695                    run_id,
11696                    pending_path,
11697                    final_path,
11698                    rows,
11699                    row_count,
11700                    min_rid,
11701                    max_rid,
11702                    content_hash: header.content_hash,
11703                });
11704                spilled_tables.insert(table_id);
11705            }
11706        }
11707
11708        // Test seam: let a test race `gc()` against this in-flight spill.
11709        if spill_guard.is_some() {
11710            if let Some(hook) = self.spill_hook.lock().as_ref() {
11711                hook();
11712            }
11713        }
11714
11715        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
11716        // Allocating row ids + building the rows here (lock order: table handle →
11717        // nothing) means the sequencer never locks a table handle while holding
11718        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
11719        // the table handle THEN the shared WAL; if the sequencer did the reverse
11720        // (WAL then handle) the two paths would deadlock (review fix: B1).
11721        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
11722        // Row ids are allocated here, before the sequencer's delta conflict
11723        // re-check, so a losing txn leaks the ids it reserved — harmless, the
11724        // u64 row-id space is monotonic and gaps are expected (spills do the same).
11725        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
11726            .take(staging.len())
11727            .collect();
11728        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
11729            .take(staging.len())
11730            .collect();
11731        {
11732            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
11733            for (index, (table_id, staged)) in staging.iter().enumerate() {
11734                commit_prepare_checkpoint(control, index)?;
11735                if matches!(staged, Staged::Delete(_))
11736                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
11737                {
11738                    indexes_by_table.entry(*table_id).or_default().push(index);
11739                }
11740            }
11741            let tables = self.tables.read();
11742            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
11743                commit_prepare_checkpoint(control, table_index)?;
11744                let handle = tables.get(&table_id).ok_or_else(|| {
11745                    MongrelError::NotFound(format!("table {table_id} not mounted"))
11746                })?;
11747                #[cfg(test)]
11748                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
11749                let mut t = handle.lock();
11750                for (prepare_index, index) in indexes.into_iter().enumerate() {
11751                    commit_prepare_checkpoint(control, prepare_index)?;
11752                    match &staging[index].1 {
11753                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
11754                            t.validate_cells_not_null(cells)?;
11755                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
11756                            for (column, value) in cells {
11757                                row.columns.insert(*column, value.clone());
11758                            }
11759                            prebuilt[index] = Some(row);
11760                        }
11761                        Staged::Delete(row_id) => {
11762                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
11763                        }
11764                        Staged::Put(_) | Staged::Truncate => {}
11765                        Staged::Update { .. } => {
11766                            return Err(MongrelError::Other(
11767                                "transaction contains an unnormalized update during row preparation"
11768                                    .into(),
11769                            ));
11770                        }
11771                    }
11772                }
11773            }
11774        }
11775
11776        // Finish every fallible index read before the commit marker can become
11777        // durable. Post-durable row/run metadata application is then entirely
11778        // in-memory and cannot stop halfway through a multi-table publish.
11779        let prepared_table_handles = {
11780            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
11781            let put_table_ids: HashSet<u64> = staging
11782                .iter()
11783                .filter_map(|(table_id, staged)| {
11784                    matches!(staged, Staged::Put(_)).then_some(*table_id)
11785                })
11786                .collect();
11787            let tables = self.tables.read();
11788            let mut handles = HashMap::with_capacity(table_ids.len());
11789            for (table_index, table_id) in table_ids.into_iter().enumerate() {
11790                commit_prepare_checkpoint(control, table_index)?;
11791                let handle = tables.get(&table_id).ok_or_else(|| {
11792                    MongrelError::NotFound(format!("table {table_id} not mounted"))
11793                })?;
11794                if put_table_ids.contains(&table_id) {
11795                    match control {
11796                        Some(control) => {
11797                            handle.lock().prepare_durable_publish_controlled(control)?
11798                        }
11799                        None => handle.lock().prepare_durable_publish()?,
11800                    }
11801                }
11802                handles.insert(table_id, handle.clone());
11803            }
11804            handles
11805        };
11806
11807        // Link large-transaction spill files before WAL durability. The guard
11808        // restores their pending names on every error before WAL append begins;
11809        // publication only attaches already-present files in memory.
11810        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
11811
11812        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
11813            .iter()
11814            .map(|run| {
11815                (
11816                    run.table_id,
11817                    run.rows.iter().map(|row| row.row_id).collect(),
11818                )
11819            })
11820            .collect();
11821        let committed_row_ids = staging
11822            .iter()
11823            .enumerate()
11824            .filter_map(|(index, (table_id, staged))| {
11825                if !matches!(staged, Staged::Put(_)) {
11826                    return None;
11827                }
11828                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
11829                    spilled_row_ids
11830                        .get_mut(table_id)
11831                        .and_then(VecDeque::pop_front)
11832                })
11833            })
11834            .collect();
11835
11836        let mut prepared_external = Vec::with_capacity(external_states.len());
11837        for (external_index, (name, state)) in external_states.iter().enumerate() {
11838            commit_prepare_checkpoint(control, external_index)?;
11839            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
11840            prepared_external.push((name.clone(), state.clone(), pending));
11841        }
11842
11843        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
11844        //
11845        // The schema barrier's Shared hold covered the prepare/validate phases
11846        // above (every catalog, constraint, and claim read ran under a stable
11847        // schema); the publish below is guarded by the catalog-generation
11848        // check, exactly as before S1B-003. Release the barrier here — before
11849        // the sequencer — so a DDL waiting on its Exclusive hold may proceed
11850        // while this commit publishes (the generation check resolves that
11851        // race). Unique/sequence/FK claims stay held until the commit ends.
11852        if context.state.is_some() {
11853            self.lock_manager
11854                .release(txn_id, &crate::locks::LockKey::schema_barrier());
11855        }
11856        let added_runs: Vec<crate::wal::AddedRun> = spilled
11857            .iter()
11858            .map(|s| crate::wal::AddedRun {
11859                table_id: s.table_id,
11860                run_id: s.run_id,
11861                row_count: s.row_count,
11862                level: 0,
11863                min_row_id: s.min_rid,
11864                max_row_id: s.max_rid,
11865                content_hash: s.content_hash,
11866            })
11867            .collect();
11868        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
11869            hook();
11870        }
11871        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
11872        // Security mutations cannot overtake an authorized commit before its
11873        // commit marker is durable.
11874        let security_guard = self.security_coordinator.gate.read();
11875        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
11876            return Err(MongrelError::Conflict(
11877                "security policy changed during write".into(),
11878            ));
11879        }
11880        if spill_guard.is_some() {
11881            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
11882                hook();
11883            }
11884        }
11885        let commit_guard = self.commit_lock.lock();
11886        let catalog_generation_result = (|| {
11887            {
11888                let catalog = self.catalog.read();
11889                for table_id in prepared_table_handles.keys() {
11890                    let is_current = catalog.tables.iter().any(|entry| {
11891                        entry.table_id == *table_id
11892                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
11893                    });
11894                    if !is_current {
11895                        return Err(MongrelError::Conflict(format!(
11896                            "table {table_id} changed during transaction preparation"
11897                        )));
11898                    }
11899                }
11900                for (name, created_epoch) in &expected_external_generations {
11901                    let current = catalog
11902                        .external_tables
11903                        .iter()
11904                        .find(|entry| entry.name == *name)
11905                        .map(|entry| entry.created_epoch);
11906                    if current != Some(*created_epoch) {
11907                        return Err(MongrelError::Conflict(format!(
11908                            "external table {name:?} changed during transaction preparation"
11909                        )));
11910                    }
11911                }
11912                for (table_id, definition) in &prepared_materialized_views {
11913                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
11914                    if current != Some(*table_id) {
11915                        return Err(MongrelError::Conflict(format!(
11916                            "materialized view {:?} changed during transaction preparation",
11917                            definition.name
11918                        )));
11919                    }
11920                }
11921                if trigger_catalog_binding(&catalog) != trigger_binding {
11922                    return Err(MongrelError::Conflict(
11923                        "trigger or referenced table generation changed during transaction preparation"
11924                            .into(),
11925                    ));
11926                }
11927            }
11928            let tables = self.tables.read();
11929            for (table_id, prepared) in &prepared_table_handles {
11930                if !tables
11931                    .get(table_id)
11932                    .is_some_and(|current| current.ptr_eq(prepared))
11933                {
11934                    return Err(MongrelError::Conflict(format!(
11935                        "table {table_id} mount changed during transaction preparation"
11936                    )));
11937                }
11938            }
11939            Ok(())
11940        })();
11941        if let Err(error) = catalog_generation_result {
11942            drop(commit_guard);
11943            for (_, _, pending) in &prepared_external {
11944                let _ = std::fs::remove_file(pending);
11945            }
11946            return Err(error);
11947        }
11948        // The commit lock keeps the next epoch stable while logical spill
11949        // records are serialized. Build them before taking the shared WAL
11950        // lock, and cap their aggregate memory/WAL footprint.
11951        let new_epoch = self.epoch.assigned().next();
11952        let mut spilled_wal_bytes = 0;
11953        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
11954        let spill_prepare = (|| {
11955            for run in &mut spilled {
11956                for row in &mut run.rows {
11957                    row.committed_epoch = new_epoch;
11958                }
11959                for rows in encode_spilled_row_chunks(
11960                    &run.rows,
11961                    &mut spilled_wal_bytes,
11962                    SPILLED_WAL_TOTAL_MAX_BYTES,
11963                    control,
11964                )? {
11965                    spilled_wal_records.push((
11966                        run.table_id,
11967                        Op::SpilledRows {
11968                            table_id: run.table_id,
11969                            rows,
11970                        },
11971                    ));
11972                }
11973            }
11974            Result::<()>::Ok(())
11975        })();
11976        if let Err(error) = spill_prepare {
11977            for (_, _, pending) in &prepared_external {
11978                let _ = std::fs::remove_file(pending);
11979            }
11980            return Err(error);
11981        }
11982        let (
11983            new_epoch,
11984            mut _epoch_guard,
11985            applies,
11986            committed_materialized_views,
11987            commit_seq,
11988            commit_ts,
11989        ) = {
11990            let mut wal = self.shared_wal.lock();
11991
11992            // Re-check only if the conflict index advanced since pre-validation
11993            // (bounded delta — spec §8.5, review fix #17). If the version is
11994            // unchanged, the pre-check result is still valid and the sequencer
11995            // does O(1) work regardless of write-set size.
11996            if self.conflicts.version() != pre_validate_version {
11997                if self.conflicts.conflicts(&write_keys, read_epoch) {
11998                    // Abort: this txn assigned no epoch yet. The prepared-run guard
11999                    // restores final run names to their pending paths on return.
12000                    drop(wal);
12001                    for (_, _, pending) in &prepared_external {
12002                        let _ = std::fs::remove_file(pending);
12003                    }
12004                    return Err(MongrelError::Conflict(
12005                        "write-write conflict (sequencer delta re-check)".into(),
12006                    ));
12007                }
12008                if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
12009                    // S1B-002 dangerous structure: a commit that landed between
12010                    // the pre-check and the sequencer invalidated a tracked
12011                    // read. Abort before assigning any epoch.
12012                    drop(wal);
12013                    for (_, _, pending) in &prepared_external {
12014                        let _ = std::fs::remove_file(pending);
12015                    }
12016                    return Err(MongrelError::SerializationFailure {
12017                        message:
12018                            "a concurrent commit invalidated this transaction's reads (sequencer re-check)"
12019                                .into(),
12020                    });
12021                }
12022            }
12023
12024            if let Some(control) = control {
12025                if let Err(error) = control.checkpoint() {
12026                    drop(wal);
12027                    for (_, _, pending) in &prepared_external {
12028                        let _ = std::fs::remove_file(pending);
12029                    }
12030                    return Err(error);
12031                }
12032            }
12033            let mut applies = Vec::<TableApplyBatch>::new();
12034            let mut apply_indexes = HashMap::<u64, usize>::new();
12035            let mut committed_materialized_views = Vec::new();
12036            let mut wal_records = spilled_wal_records;
12037
12038            let mut index = 0;
12039            while index < staging.len() {
12040                let table_id = staging[index].0;
12041                let handle = prepared_table_handles
12042                    .get(&table_id)
12043                    .cloned()
12044                    .ok_or_else(|| {
12045                        MongrelError::NotFound(format!("table {table_id} not prepared"))
12046                    })?;
12047                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
12048                    let index = applies.len();
12049                    applies.push(TableApplyBatch {
12050                        table_id,
12051                        handle,
12052                        ops: Vec::new(),
12053                    });
12054                    index
12055                });
12056
12057                // Skip puts for tables that were spilled — their data is in a
12058                // pending run, not in streamed Put records.
12059                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
12060                {
12061                    index += 1;
12062                    continue;
12063                }
12064
12065                match &staging[index].1 {
12066                    Staged::Put(_) => {
12067                        let mut rows = Vec::new();
12068                        while index < staging.len()
12069                            && staging[index].0 == table_id
12070                            && matches!(&staging[index].1, Staged::Put(_))
12071                        {
12072                            let mut row = prebuilt[index].take().ok_or_else(|| {
12073                                MongrelError::Other(
12074                                    "transaction prepare lost a prebuilt put row".into(),
12075                                )
12076                            })?;
12077                            row.committed_epoch = new_epoch;
12078                            rows.push(row);
12079                            index += 1;
12080                        }
12081                        let payload = bincode::serialize(&rows)
12082                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
12083                        wal_records.push((
12084                            table_id,
12085                            Op::Put {
12086                                table_id,
12087                                rows: payload,
12088                            },
12089                        ));
12090                        applies[batch_index].ops.push(StagedOp::Put(rows));
12091                    }
12092                    Staged::Delete(_) => {
12093                        let mut row_ids = Vec::new();
12094                        while index < staging.len()
12095                            && staging[index].0 == table_id
12096                            && matches!(&staging[index].1, Staged::Delete(_))
12097                        {
12098                            let Staged::Delete(row_id) = &staging[index].1 else {
12099                                return Err(MongrelError::Other(
12100                                    "transaction delete batch changed during WAL preparation"
12101                                        .into(),
12102                                ));
12103                            };
12104                            if let Some(before) = &delete_images[index] {
12105                                wal_records.push((
12106                                    table_id,
12107                                    Op::BeforeImage {
12108                                        table_id,
12109                                        row_id: *row_id,
12110                                        row: bincode::serialize(before).map_err(|error| {
12111                                            MongrelError::Other(format!(
12112                                                "before-image serialize: {error}"
12113                                            ))
12114                                        })?,
12115                                    },
12116                                ));
12117                            }
12118                            row_ids.push(*row_id);
12119                            index += 1;
12120                        }
12121                        wal_records.push((
12122                            table_id,
12123                            Op::Delete {
12124                                table_id,
12125                                row_ids: row_ids.clone(),
12126                            },
12127                        ));
12128                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
12129                    }
12130                    Staged::Truncate => {
12131                        wal_records.push((table_id, Op::TruncateTable { table_id }));
12132                        applies[batch_index].ops.push(StagedOp::Truncate);
12133                        index += 1;
12134                    }
12135                    Staged::Update { .. } => {
12136                        return Err(MongrelError::Other(
12137                            "transaction contains an unnormalized update at the sequencer".into(),
12138                        ));
12139                    }
12140                }
12141            }
12142
12143            for (name, state, _) in &prepared_external {
12144                wal_records.push((
12145                    EXTERNAL_TABLE_ID,
12146                    Op::ExternalTableState {
12147                        name: name.clone(),
12148                        state: state.clone(),
12149                    },
12150                ));
12151            }
12152
12153            for (table_id, definition) in &prepared_materialized_views {
12154                let mut definition = definition.clone();
12155                definition.last_refresh_epoch = new_epoch.0;
12156                wal_records.push((
12157                    *table_id,
12158                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
12159                        name: definition.name.clone(),
12160                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
12161                    }),
12162                ));
12163                committed_materialized_views.push(definition);
12164            }
12165            if !committed_materialized_views.is_empty() {
12166                let mut next_catalog = self.catalog.read().clone();
12167                for definition in &committed_materialized_views {
12168                    if let Some(existing) = next_catalog
12169                        .materialized_views
12170                        .iter_mut()
12171                        .find(|existing| existing.name == definition.name)
12172                    {
12173                        *existing = definition.clone();
12174                    } else {
12175                        next_catalog.materialized_views.push(definition.clone());
12176                    }
12177                }
12178                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
12179                wal_records.push((
12180                    WAL_TABLE_ID,
12181                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
12182                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
12183                    }),
12184                ));
12185            }
12186
12187            if let Some(control) = control {
12188                if let Err(error) = control.checkpoint() {
12189                    drop(wal);
12190                    for (_, _, pending) in &prepared_external {
12191                        let _ = std::fs::remove_file(pending);
12192                    }
12193                    return Err(error);
12194                }
12195            }
12196            if let Some(before_commit) = before_commit.as_mut() {
12197                if let Err(error) = before_commit() {
12198                    drop(wal);
12199                    for (_, _, pending) in &prepared_external {
12200                        let _ = std::fs::remove_file(pending);
12201                    }
12202                    return Err(error);
12203                }
12204            }
12205
12206            let assigned_epoch = self.epoch.bump_assigned();
12207            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
12208            if assigned_epoch != new_epoch {
12209                for (_, _, pending) in &prepared_external {
12210                    let _ = std::fs::remove_file(pending);
12211                }
12212                return Err(MongrelError::Conflict(
12213                    "commit epoch changed while sequencer lock was held".into(),
12214                ));
12215            }
12216
12217            // S1B-004 steps 5–6: assign the commit timestamp (spec §8.2:
12218            // strictly greater than every participant read/write timestamp of
12219            // this transaction — the transaction's HLC read timestamp captured
12220            // at `begin` is threaded through the commit context) and enter
12221            // commit-critical state before the proposal can become durable.
12222            // Internal commits with no transaction timestamp allocate from the
12223            // fail-closed clock gate instead.
12224            let commit_ts = match context.read_ts {
12225                Some(read_ts) => self.hlc.commit_timestamp([read_ts]),
12226                None => self.hlc.now().map_err(|skew| {
12227                    MongrelError::Other(format!(
12228                        "clock skew rejected commit timestamp allocation: {skew}"
12229                    ))
12230                })?,
12231            };
12232            if let Some(state) = &context.state {
12233                state.enter_commit_critical();
12234            }
12235
12236            // From this point the outcome can become ambiguous. Keep prepared
12237            // spill files at the final names referenced by a possibly durable
12238            // commit marker; orphan cleanup is safe when the append did fail.
12239            prepared_run_links.disarm();
12240
12241            // FND-004 (spec §9.4): the commit log owns the append step for the
12242            // transaction command (records + commit marker). The commit path
12243            // proposes through it; durability and the receipt follow below.
12244            let commit_seq = self
12245                .commit_log
12246                .append_transaction(
12247                    &mut wal,
12248                    txn_id,
12249                    new_epoch,
12250                    commit_ts,
12251                    wal_records,
12252                    &added_runs,
12253                )
12254                .map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
12255
12256            // Record the conflict + assign the epoch under the WAL lock so commit
12257            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
12258            // moves out of this critical section to the group-commit coordinator
12259            // so concurrent committers share a single leader fsync.
12260            self.conflicts.record(&write_keys, new_epoch);
12261            (
12262                new_epoch,
12263                _epoch_guard,
12264                applies,
12265                committed_materialized_views,
12266                commit_seq,
12267                commit_ts,
12268            )
12269        };
12270        drop(commit_guard);
12271
12272        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
12273        let receipt =
12274            self.await_durable_commit_with_ts(txn_id, commit_seq, new_epoch, commit_ts)?;
12275        drop(security_guard);
12276
12277        // ── S1B-005: record the receipt against the reserved idempotency key
12278        // (durable before the caller can observe success), so a repeated key
12279        // with an identical request replays this receipt.
12280        if let Some(request) = &idempotency_request {
12281            if let Err(error) =
12282                self.idempotency
12283                    .complete(request, txn_id, new_epoch, receipt.commit_ts)
12284            {
12285                return Err(MongrelError::DurableCommit {
12286                    epoch: new_epoch.0,
12287                    message: format!("idempotency record was not durable: {error}"),
12288                });
12289            }
12290            if let Some(guard) = idempotency_guard.as_mut() {
12291                guard.disarm();
12292            }
12293        }
12294
12295        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
12296        let publish_result: Result<()> = {
12297            let mut first_error = None;
12298            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
12299            for run in &spilled {
12300                spilled_by_table.entry(run.table_id).or_default().push(run);
12301            }
12302            let mut modified_tables = Vec::with_capacity(applies.len());
12303            // Apply every table completely before any fallible manifest write.
12304            // The visible epoch remains unchanged until all tables are coherent.
12305            for batch in applies {
12306                #[cfg(test)]
12307                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
12308                let mut t = batch.handle.lock();
12309                for op in batch.ops {
12310                    match op {
12311                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
12312                        StagedOp::Delete(row_ids) => {
12313                            for row_id in row_ids {
12314                                t.apply_delete(row_id, new_epoch);
12315                            }
12316                        }
12317                        StagedOp::Truncate => t.apply_truncate(new_epoch),
12318                    }
12319                }
12320                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
12321                    for run in runs {
12322                        t.link_run(crate::manifest::RunRef {
12323                            run_id: run.run_id,
12324                            level: 0,
12325                            epoch_created: new_epoch.0,
12326                            row_count: run.row_count,
12327                        });
12328                        t.apply_run_metadata_prepared(&run.rows)?;
12329                        if truncated_tables.contains(&batch.table_id) {
12330                            // TRUNCATE + spilled puts fully describe this table at
12331                            // the commit epoch. Endorse the epoch so clean-reopen
12332                            // recovery does not replay the truncate over the
12333                            // already-linked replacement run.
12334                            t.set_flushed_epoch(new_epoch);
12335                        }
12336                    }
12337                }
12338                t.invalidate_pending_cache();
12339                drop(t);
12340                modified_tables.push(batch.handle);
12341            }
12342
12343            // Checkpoint only after every live table carries the durable state.
12344            // Continue after one checkpoint failure so runtime publication stays
12345            // all-or-nothing; WAL recovery repairs failed files on reopen.
12346            for handle in modified_tables {
12347                #[cfg(test)]
12348                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
12349                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
12350                    first_error.get_or_insert(error);
12351                }
12352            }
12353            for (name, _, pending) in &prepared_external {
12354                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
12355                    first_error.get_or_insert(error);
12356                }
12357            }
12358            if !committed_materialized_views.is_empty() {
12359                let mut next_catalog = self.catalog.read().clone();
12360                for definition in committed_materialized_views {
12361                    if let Some(existing) = next_catalog
12362                        .materialized_views
12363                        .iter_mut()
12364                        .find(|existing| existing.name == definition.name)
12365                    {
12366                        *existing = definition;
12367                    } else {
12368                        next_catalog.materialized_views.push(definition);
12369                    }
12370                }
12371                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
12372                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
12373                    first_error.get_or_insert(error);
12374                }
12375            }
12376            match first_error {
12377                Some(error) => Err(error),
12378                None => Ok(()),
12379            }
12380        };
12381
12382        if has_changes {
12383            let _ = self.change_wake.send(());
12384        }
12385        self.finish_durable_publish(new_epoch, &mut _epoch_guard, &receipt, publish_result)?;
12386        // S1B-001: the commit is durable and published — record the receipt on
12387        // the formal state. (Post-publish errors above return `DurableCommit`
12388        // and deliberately leave the state `CommitCritical`.)
12389        if let Some(state) = &context.state {
12390            state.committed(receipt.clone());
12391        }
12392        Ok((new_epoch, committed_row_ids))
12393    }
12394
12395    /// Register a read snapshot at the current visible epoch and return it with
12396    /// a guard that retains it for GC until dropped.
12397    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
12398        let e = self.epoch.visible();
12399        let g = self.snapshots.register(e);
12400        (Snapshot::at(e), g)
12401    }
12402
12403    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
12404    /// retention.
12405    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
12406        let e = self.epoch.visible();
12407        let g = self.snapshots.register_owned(e);
12408        (Snapshot::at(e), g)
12409    }
12410
12411    /// Configure a rolling history window measured in prior commit epochs.
12412    /// The first enable starts at the current epoch because earlier versions
12413    /// may already have been compacted. Increasing the window likewise cannot
12414    /// recreate history that fell outside the previous guarantee.
12415    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
12416        let _guard = self.ddl_lock.lock();
12417        let current = self.epoch.visible();
12418        let (old_epochs, old_start) = self.snapshots.history_config();
12419        let earliest_already_guaranteed = if old_epochs == 0 {
12420            current
12421        } else {
12422            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
12423        };
12424        let start = if epochs == 0 {
12425            current
12426        } else {
12427            earliest_already_guaranteed
12428        };
12429        let published = std::cell::Cell::new(false);
12430        let result = write_history_retention(&self.root, epochs, start, || {
12431            self.snapshots.configure_history(epochs, start);
12432            published.set(true);
12433        });
12434        match result {
12435            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
12436                epoch: current.0,
12437                message: format!("history-retention publication was not durable: {error}"),
12438            }),
12439            result => result,
12440        }
12441    }
12442
12443    pub fn history_retention_epochs(&self) -> u64 {
12444        self.snapshots.history_config().0
12445    }
12446
12447    pub fn earliest_retained_epoch(&self) -> Epoch {
12448        let current = self.epoch.visible();
12449        self.snapshots.history_floor(current).unwrap_or(current)
12450    }
12451
12452    /// Pin a guaranteed historical epoch for the lifetime of the returned
12453    /// guard. Rejects future epochs and epochs outside the configured window.
12454    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
12455        let current = self.epoch.visible();
12456        if epoch > current {
12457            return Err(MongrelError::InvalidArgument(format!(
12458                "epoch {} is in the future; current epoch is {}",
12459                epoch.0, current.0
12460            )));
12461        }
12462        let earliest = self.earliest_retained_epoch();
12463        if epoch < earliest {
12464            return Err(MongrelError::InvalidArgument(format!(
12465                "epoch {} is no longer retained; earliest available epoch is {}",
12466                epoch.0, earliest.0
12467            )));
12468        }
12469        let guard = self.snapshots.register_owned(epoch);
12470        Ok((Snapshot::at(epoch), guard))
12471    }
12472
12473    /// Names of all live tables.
12474    pub fn table_names(&self) -> Vec<String> {
12475        self.catalog
12476            .read()
12477            .tables
12478            .iter()
12479            .filter(|t| matches!(t.state, TableState::Live))
12480            .map(|t| t.name.clone())
12481            .collect()
12482    }
12483
12484    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
12485    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
12486    /// reaped on the next open. Call this as the last action before a
12487    /// short-lived process (CLI, one-shot script) exits. The daemon does not
12488    /// need this — its background auto-compactor handles run management.
12489    pub fn close(&self) -> Result<()> {
12490        for name in self.table_names() {
12491            if let Ok(handle) = self.table(&name) {
12492                if let Err(e) = handle.lock().close() {
12493                    eprintln!("[close] flush failed for {name}: {e}");
12494                }
12495            }
12496        }
12497        Ok(())
12498    }
12499
12500    /// Compact every mounted table: merge all sorted runs into one clean run
12501    /// so query cost stays flat (single-run fast path) instead of growing
12502    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
12503    /// rows to reclaim. Each table
12504    /// is locked individually for its own compaction; snapshot retention is
12505    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
12506    pub fn compact(&self) -> Result<(usize, usize)> {
12507        self.require(&crate::auth::Permission::Ddl)?;
12508        // S1A-004: admit the compaction pass as one core operation.
12509        let _operation = self.admit_operation()?;
12510        let mut compacted = 0;
12511        let mut skipped = 0;
12512        for name in self.table_names() {
12513            let Ok(handle) = self.table(&name) else {
12514                continue;
12515            };
12516            {
12517                let mut t = handle.lock();
12518                let before = t.run_count();
12519                if before < 2 && !t.should_compact() {
12520                    skipped += 1;
12521                    continue;
12522                }
12523                match t.compact() {
12524                    Ok(()) => {
12525                        let after = t.run_count();
12526                        compacted += 1;
12527                        eprintln!("[compact] {name}: {before} -> {after} runs");
12528                    }
12529                    Err(e) => {
12530                        eprintln!("[compact] {name}: compaction failed: {e}");
12531                        skipped += 1;
12532                    }
12533                }
12534            }
12535        }
12536        Ok((compacted, skipped))
12537    }
12538
12539    /// Compact a single table by name. Returns `Ok(true)` if it was
12540    /// compacted, `Ok(false)` if skipped (< 2 runs).
12541    pub fn compact_table(&self, name: &str) -> Result<bool> {
12542        self.require(&crate::auth::Permission::Ddl)?;
12543        let handle = self.table(name)?;
12544        let mut t = handle.lock();
12545        let before = t.run_count();
12546        if before < 2 {
12547            return Ok(false);
12548        }
12549        t.compact()?;
12550        Ok(t.run_count() < before)
12551    }
12552
12553    /// Look up a live table by name.
12554    pub fn table(&self, name: &str) -> Result<TableHandle> {
12555        self.ensure_owner_process()?;
12556        let cat = self.catalog.read();
12557        let entry = cat
12558            .live(name)
12559            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
12560        let id = entry.table_id;
12561        drop(cat);
12562        self.tables
12563            .read()
12564            .get(&id)
12565            .cloned()
12566            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
12567    }
12568
12569    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
12570    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
12571    pub fn has_ttl_tables(&self) -> bool {
12572        self.tables
12573            .read()
12574            .values()
12575            .any(|table| table.lock().ttl().is_some())
12576    }
12577
12578    /// Resolve a live table id → mounted handle (used by the constraint
12579    /// validation pass and other id-qualified internal paths).
12580    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
12581        self.tables
12582            .read()
12583            .get(&id)
12584            .cloned()
12585            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
12586    }
12587
12588    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
12589    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
12590    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
12591    /// allocation; explicit values merely advance the counter (an
12592    /// order-insensitive `max` under the table lock) and must not serialize.
12593    pub(crate) fn table_auto_inc_would_allocate(
12594        &self,
12595        table_id: u64,
12596        cells: &[(u16, Value)],
12597    ) -> bool {
12598        let catalog = self.catalog.read();
12599        let Some(entry) = catalog
12600            .tables
12601            .iter()
12602            .find(|entry| entry.table_id == table_id)
12603        else {
12604            return false;
12605        };
12606        let Some(column) = entry.schema.auto_increment_column() else {
12607            return false;
12608        };
12609        matches!(
12610            cells.iter().find(|(id, _)| *id == column.id),
12611            None | Some((_, Value::Null))
12612        )
12613    }
12614
12615    /// Create a new table. The DDL is first logged to the shared WAL
12616    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
12617    /// BEFORE the in-memory catalog and table map are mutated; the catalog
12618    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
12619    /// that sees a stale catalog still recovers the table by replaying the Ddl.
12620    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
12621        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
12622            return Err(MongrelError::InvalidArgument(format!(
12623                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
12624            )));
12625        }
12626        self.create_table_with_state(name, schema, TableState::Live)
12627    }
12628
12629    /// Create a durable but non-queryable CTAS build table.
12630    #[doc(hidden)]
12631    pub fn create_building_table(
12632        &self,
12633        build_name: &str,
12634        intended_name: &str,
12635        query_id: &str,
12636        schema: Schema,
12637    ) -> Result<u64> {
12638        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12639            || intended_name.is_empty()
12640            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12641            || query_id.is_empty()
12642        {
12643            return Err(MongrelError::InvalidArgument(
12644                "invalid CTAS building-table identity".into(),
12645            ));
12646        }
12647        self.create_table_with_state(
12648            build_name,
12649            schema,
12650            TableState::Building {
12651                intended_name: intended_name.to_string(),
12652                query_id: query_id.to_string(),
12653                created_at_unix_nanos: current_unix_nanos(),
12654                replaces_table_id: None,
12655            },
12656        )
12657    }
12658
12659    /// Create a hidden schema-rebuild table while the intended target remains
12660    /// live. Publication later validates that the same target is still live.
12661    #[doc(hidden)]
12662    pub fn create_rebuilding_table(
12663        &self,
12664        build_name: &str,
12665        intended_name: &str,
12666        query_id: &str,
12667        schema: Schema,
12668    ) -> Result<u64> {
12669        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12670            || intended_name.is_empty()
12671            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12672            || query_id.is_empty()
12673        {
12674            return Err(MongrelError::InvalidArgument(
12675                "invalid rebuilding-table identity".into(),
12676            ));
12677        }
12678        let replaces_table_id = self
12679            .catalog
12680            .read()
12681            .live(intended_name)
12682            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
12683            .table_id;
12684        self.create_table_with_state(
12685            build_name,
12686            schema,
12687            TableState::Building {
12688                intended_name: intended_name.to_string(),
12689                query_id: query_id.to_string(),
12690                created_at_unix_nanos: current_unix_nanos(),
12691                replaces_table_id: Some(replaces_table_id),
12692            },
12693        )
12694    }
12695
12696    fn create_table_with_state(
12697        &self,
12698        name: &str,
12699        schema: Schema,
12700        state: TableState,
12701    ) -> Result<u64> {
12702        use crate::wal::DdlOp;
12703        use std::sync::atomic::Ordering;
12704
12705        self.require(&crate::auth::Permission::Ddl)?;
12706        if self.poisoned.load(Ordering::Relaxed) {
12707            return Err(MongrelError::Other(
12708                "database poisoned by fsync error".into(),
12709            ));
12710        }
12711        // S1A-004: admit the DDL as one core operation.
12712        let _operation = self.admit_operation()?;
12713        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
12714        let _g = self.ddl_lock.lock();
12715        let _security_write = self.security_write()?;
12716        self.require(&crate::auth::Permission::Ddl)?;
12717        {
12718            let cat = self.catalog.read();
12719            match &state {
12720                TableState::Live => {
12721                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
12722                        return Err(MongrelError::InvalidArgument(format!(
12723                            "table {name:?} already exists or is being built"
12724                        )));
12725                    }
12726                }
12727                TableState::Building {
12728                    intended_name,
12729                    replaces_table_id,
12730                    ..
12731                } => {
12732                    let target_matches = match replaces_table_id {
12733                        Some(table_id) => cat
12734                            .live(intended_name)
12735                            .is_some_and(|entry| entry.table_id == *table_id),
12736                        None => cat.live(intended_name).is_none(),
12737                    };
12738                    if !target_matches || cat.building_for(intended_name).is_some() {
12739                        return Err(MongrelError::InvalidArgument(format!(
12740                            "table {intended_name:?} changed or is already being built"
12741                        )));
12742                    }
12743                    if cat.building(name).is_some() {
12744                        return Err(MongrelError::InvalidArgument(format!(
12745                            "building table {name:?} already exists"
12746                        )));
12747                    }
12748                }
12749                TableState::Dropped { .. } => {
12750                    return Err(MongrelError::InvalidArgument(
12751                        "cannot create a dropped table".into(),
12752                    ));
12753                }
12754            }
12755        }
12756
12757        // Allocate id + epoch + txn id under the commit lock so the DDL commit
12758        // is serialized with data commits (in-order publish). Live creates
12759        // draw their table id from the routed catalog command below (S1F-001);
12760        // CTAS building-table creates keep the legacy direct allocation.
12761        let commit_lock = Arc::clone(&self.commit_lock);
12762        let _c = commit_lock.lock();
12763        let live_create = matches!(state, TableState::Live);
12764        let legacy_table_id = if live_create {
12765            None
12766        } else {
12767            let mut cat = self.catalog.write();
12768            let id = cat.next_table_id;
12769            cat.next_table_id = id
12770                .checked_add(1)
12771                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
12772            Some(id)
12773        };
12774        let epoch = self.epoch.bump_assigned();
12775        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
12776        let txn_id = self.alloc_txn_id()?;
12777
12778        let mut schema = schema;
12779        if let Some(table_id) = legacy_table_id {
12780            // Stamp the schema_id with the unique table_id so every table in
12781            // the database has a distinct schema_id (caller-provided values
12782            // are ignored to prevent collisions).
12783            schema.schema_id = table_id;
12784            // Defense in depth: reject an invalid schema BEFORE any durable
12785            // side-effect. `Table::create_in` re-validates, but by then the
12786            // DDL has already been appended to the shared WAL; a failing
12787            // create_in would leave a dangling entry that `recover_ddl_from_wal`
12788            // replays without re-validating, corrupting the catalog on reopen.
12789            // Validating here keeps the WAL free of schemas that can never be
12790            // opened.
12791            schema.validate_auto_increment()?;
12792            schema.validate_defaults()?;
12793            schema.validate_ai()?;
12794            for index in &schema.indexes {
12795                index.validate_options()?;
12796            }
12797            for constraint in &schema.constraints.checks {
12798                constraint.expr.validate()?;
12799            }
12800        }
12801
12802        let mut next_catalog = self.catalog.read().clone();
12803        let (table_id, schema) = match legacy_table_id {
12804            Some(table_id) => {
12805                next_catalog.tables.push(CatalogEntry {
12806                    table_id,
12807                    name: name.to_string(),
12808                    schema: schema.clone(),
12809                    state: state.clone(),
12810                    created_epoch: epoch.0,
12811                });
12812                (table_id, schema)
12813            }
12814            None => {
12815                // S1F-001: the mutation is a versioned catalog command —
12816                // schema validation (the same five validators), table-id
12817                // allocation, and schema_id stamping all resolve inside it.
12818                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
12819                    name: name.to_string(),
12820                    schema,
12821                    created_epoch: epoch.0,
12822                };
12823                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
12824                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
12825                    return Err(MongrelError::Other(
12826                        "CreateTable resolved to an unexpected catalog delta".into(),
12827                    ));
12828                };
12829                (entry.table_id, entry.schema)
12830            }
12831        };
12832        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
12833
12834        // Build the complete mounted table before its DDL can become durable.
12835        // Any failure removes the unpublished directory and abandons the epoch.
12836        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
12837        let canonical_tdir = self.root.join(&table_relative);
12838        let table_root = Arc::new(
12839            self.durable_root
12840                .create_directory_all_pinned(&table_relative)?,
12841        );
12842        let tdir = table_root.io_path()?;
12843        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
12844        let ctx = SharedCtx {
12845            root_guard: Some(table_root),
12846            epoch: Arc::clone(&self.epoch),
12847            page_cache: Arc::clone(&self.page_cache),
12848            decoded_cache: Arc::clone(&self.decoded_cache),
12849            snapshots: Arc::clone(&self.snapshots),
12850            kek: self.kek.clone(),
12851            commit_lock: Arc::clone(&self.commit_lock),
12852            shared: Some(crate::engine::SharedWalCtx {
12853                wal: Arc::clone(&self.shared_wal),
12854                group: Arc::clone(&self.group),
12855                poisoned: Arc::clone(&self.poisoned),
12856                txn_ids: Arc::clone(&self.next_txn_id),
12857                change_wake: self.change_wake.clone(),
12858                lifecycle: Arc::clone(&self.lifecycle),
12859            }),
12860            table_name: Some(name.to_string()),
12861            auth: self.table_auth_checker(),
12862            read_only: self.read_only,
12863        };
12864        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
12865
12866        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
12867        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
12868        let schema_json = DdlOp::encode_schema(&schema)?;
12869        let ddl = match &state {
12870            TableState::Live => DdlOp::CreateTable {
12871                table_id,
12872                name: name.to_string(),
12873                schema_json,
12874            },
12875            TableState::Building {
12876                intended_name,
12877                query_id,
12878                created_at_unix_nanos,
12879                replaces_table_id,
12880            } => match replaces_table_id {
12881                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
12882                    table_id,
12883                    build_name: name.to_string(),
12884                    intended_name: intended_name.clone(),
12885                    query_id: query_id.clone(),
12886                    created_at_unix_nanos: *created_at_unix_nanos,
12887                    replaces_table_id: *replaces_table_id,
12888                    schema_json,
12889                },
12890                None => DdlOp::CreateBuildingTable {
12891                    table_id,
12892                    build_name: name.to_string(),
12893                    intended_name: intended_name.clone(),
12894                    query_id: query_id.clone(),
12895                    created_at_unix_nanos: *created_at_unix_nanos,
12896                    schema_json,
12897                },
12898            },
12899            TableState::Dropped { .. } => {
12900                return Err(MongrelError::InvalidArgument(
12901                    "cannot create a table in dropped state".into(),
12902                ));
12903            }
12904        };
12905        let commit_seq = {
12906            let mut wal = self.shared_wal.lock();
12907            let append: Result<u64> = (|| {
12908                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
12909                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12910                wal.append_commit(txn_id, epoch, &[])
12911            })();
12912            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
12913        };
12914        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
12915        pending_table_dir.disarm();
12916
12917        // Publish the mounted table and catalog in memory even if the catalog
12918        // checkpoint fails after the WAL commit.
12919        self.tables
12920            .write()
12921            .insert(table_id, TableHandle::new(table));
12922        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12923        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
12924        Ok(table_id)
12925    }
12926
12927    /// Logically drop a table, logging the DDL through the shared WAL first.
12928    pub fn drop_table(&self, name: &str) -> Result<()> {
12929        self.drop_table_with_epoch(name).map(|_| ())
12930    }
12931
12932    /// Logically drop a table and return the exact publication epoch.
12933    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
12934        self.drop_table_with_state(name, false, None)
12935    }
12936
12937    pub fn drop_table_with_epoch_controlled<F>(
12938        &self,
12939        name: &str,
12940        mut before_commit: F,
12941    ) -> Result<Epoch>
12942    where
12943        F: FnMut() -> Result<()>,
12944    {
12945        self.drop_table_with_state(name, false, Some(&mut before_commit))
12946    }
12947
12948    /// Discard an unpublished CTAS build.
12949    #[doc(hidden)]
12950    pub fn discard_building_table(&self, name: &str) -> Result<()> {
12951        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
12952            return Err(MongrelError::InvalidArgument(
12953                "not a CTAS building table".into(),
12954            ));
12955        }
12956        self.drop_table_with_state(name, true, None).map(|_| ())
12957    }
12958
12959    fn drop_table_with_state(
12960        &self,
12961        name: &str,
12962        building: bool,
12963        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
12964    ) -> Result<Epoch> {
12965        use crate::wal::DdlOp;
12966        use std::sync::atomic::Ordering;
12967
12968        self.require(&crate::auth::Permission::Ddl)?;
12969        if self.poisoned.load(Ordering::Relaxed) {
12970            return Err(MongrelError::Other(
12971                "database poisoned by fsync error".into(),
12972            ));
12973        }
12974        // S1A-004: admit the DDL as one core operation.
12975        let _operation = self.admit_operation()?;
12976        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
12977        let _g = self.ddl_lock.lock();
12978        let _security_write = self.security_write()?;
12979        self.require(&crate::auth::Permission::Ddl)?;
12980        let table_id = {
12981            let cat = self.catalog.read();
12982            if building {
12983                cat.building(name)
12984            } else {
12985                cat.live(name)
12986            }
12987            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
12988            .table_id
12989        };
12990
12991        let commit_lock = Arc::clone(&self.commit_lock);
12992        let _c = commit_lock.lock();
12993        let epoch = self.epoch.bump_assigned();
12994        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
12995        let txn_id = self.alloc_txn_id()?;
12996        let mut next_catalog = self.catalog.read().clone();
12997        if building {
12998            // CTAS building-table discards stay on the legacy mutation: the
12999            // command model's `DropTable` resolves live tables only.
13000            let entry = next_catalog
13001                .tables
13002                .iter_mut()
13003                .find(|t| t.table_id == table_id)
13004                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13005            entry.state = TableState::Dropped { at_epoch: epoch.0 };
13006            next_catalog.triggers.retain(|trigger| {
13007                !matches!(
13008                    &trigger.trigger.target,
13009                    TriggerTarget::Table(target) if target == name
13010                )
13011            });
13012            next_catalog
13013                .materialized_views
13014                .retain(|definition| definition.name != name);
13015            next_catalog
13016                .security
13017                .rls_tables
13018                .retain(|table| table != name);
13019            next_catalog
13020                .security
13021                .policies
13022                .retain(|policy| policy.table != name);
13023            next_catalog
13024                .security
13025                .masks
13026                .retain(|mask| mask.table != name);
13027            for role in &mut next_catalog.roles {
13028                role.permissions
13029                    .retain(|permission| permission_table(permission) != Some(name));
13030            }
13031            advance_security_version(&mut next_catalog)?;
13032        } else {
13033            // S1F-001: a plain live-table drop is a versioned catalog command
13034            // (the delta mirrors the legacy mutation above, cascading
13035            // triggers, materialized views, and table-scoped security state).
13036            let command = crate::catalog_cmds::CatalogCommand::DropTable {
13037                name: name.to_string(),
13038                at_epoch: epoch.0,
13039            };
13040            self.apply_catalog_command_to(&mut next_catalog, command)?;
13041        }
13042        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13043        let commit_seq = {
13044            let mut wal = self.shared_wal.lock();
13045            if let Some(before_commit) = before_commit {
13046                before_commit()?;
13047            }
13048            let append: Result<u64> = (|| {
13049                wal.append(
13050                    txn_id,
13051                    table_id,
13052                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
13053                )?;
13054                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13055                wal.append_commit(txn_id, epoch, &[])
13056            })();
13057            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13058        };
13059        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13060
13061        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
13062        self.tables.write().remove(&table_id);
13063        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
13064        Ok(epoch)
13065    }
13066
13067    /// Rename a live table. `name` must exist and `new_name` must not collide
13068    /// with any live table; both checks run under `ddl_lock` so they are atomic
13069    /// with the rename and with concurrent `create_table` existence checks (no
13070    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
13071    /// side-effects. The rename is logged to the shared WAL as
13072    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
13073    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
13074    /// the in-memory object does not move — only the catalog name changes).
13075    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
13076        self.rename_table_with_epoch(name, new_name).map(|_| ())
13077    }
13078
13079    /// Rename a table and return its exact publication epoch.
13080    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
13081        self.rename_table_with_epoch_inner(name, new_name, None)
13082    }
13083
13084    pub fn rename_table_with_epoch_controlled<F>(
13085        &self,
13086        name: &str,
13087        new_name: &str,
13088        mut before_commit: F,
13089    ) -> Result<Epoch>
13090    where
13091        F: FnMut() -> Result<()>,
13092    {
13093        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
13094    }
13095
13096    fn rename_table_with_epoch_inner(
13097        &self,
13098        name: &str,
13099        new_name: &str,
13100        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13101    ) -> Result<Epoch> {
13102        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13103            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13104        {
13105            return Err(MongrelError::InvalidArgument(
13106                "the CTAS building-table namespace is reserved".into(),
13107            ));
13108        }
13109        self.rename_table_with_state(name, new_name, false, None, before_commit)
13110    }
13111
13112    /// Atomically publish a hidden CTAS build under its intended live name.
13113    #[doc(hidden)]
13114    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
13115        self.publish_building_table_inner(build_name, new_name, None)
13116    }
13117
13118    #[doc(hidden)]
13119    pub fn publish_building_table_controlled<F>(
13120        &self,
13121        build_name: &str,
13122        new_name: &str,
13123        mut before_commit: F,
13124    ) -> Result<Epoch>
13125    where
13126        F: FnMut() -> Result<()>,
13127    {
13128        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
13129    }
13130
13131    fn publish_building_table_inner(
13132        &self,
13133        build_name: &str,
13134        new_name: &str,
13135        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13136    ) -> Result<Epoch> {
13137        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13138            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13139        {
13140            return Err(MongrelError::InvalidArgument(
13141                "invalid CTAS publish identity".into(),
13142            ));
13143        }
13144        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
13145    }
13146
13147    /// Atomically publish a hidden build and its materialized-view definition.
13148    #[doc(hidden)]
13149    pub fn publish_materialized_building_table(
13150        &self,
13151        build_name: &str,
13152        new_name: &str,
13153        definition: crate::catalog::MaterializedViewEntry,
13154    ) -> Result<Epoch> {
13155        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
13156    }
13157
13158    #[doc(hidden)]
13159    pub fn publish_materialized_building_table_controlled<F>(
13160        &self,
13161        build_name: &str,
13162        new_name: &str,
13163        definition: crate::catalog::MaterializedViewEntry,
13164        mut before_commit: F,
13165    ) -> Result<Epoch>
13166    where
13167        F: FnMut() -> Result<()>,
13168    {
13169        self.publish_materialized_building_table_inner(
13170            build_name,
13171            new_name,
13172            definition,
13173            Some(&mut before_commit),
13174        )
13175    }
13176
13177    fn publish_materialized_building_table_inner(
13178        &self,
13179        build_name: &str,
13180        new_name: &str,
13181        definition: crate::catalog::MaterializedViewEntry,
13182        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13183    ) -> Result<Epoch> {
13184        if definition.name != new_name || definition.query.trim().is_empty() {
13185            return Err(MongrelError::InvalidArgument(
13186                "invalid materialized-view publication".into(),
13187            ));
13188        }
13189        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
13190    }
13191
13192    /// Atomically replace a still-live table with its completed hidden rebuild.
13193    #[doc(hidden)]
13194    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
13195        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
13196    }
13197
13198    #[doc(hidden)]
13199    pub fn publish_rebuilding_table_controlled<F>(
13200        &self,
13201        build_name: &str,
13202        new_name: &str,
13203        mut before_commit: F,
13204    ) -> Result<Epoch>
13205    where
13206        F: FnMut() -> Result<()>,
13207    {
13208        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
13209    }
13210
13211    /// Atomically replace a live materialized-view table and its definition.
13212    #[doc(hidden)]
13213    pub fn publish_materialized_rebuilding_table_controlled<F>(
13214        &self,
13215        build_name: &str,
13216        new_name: &str,
13217        definition: crate::catalog::MaterializedViewEntry,
13218        mut before_commit: F,
13219    ) -> Result<Epoch>
13220    where
13221        F: FnMut() -> Result<()>,
13222    {
13223        self.publish_rebuilding_table_inner(
13224            build_name,
13225            new_name,
13226            Some(definition),
13227            Some(&mut before_commit),
13228        )
13229    }
13230
13231    fn publish_rebuilding_table_inner(
13232        &self,
13233        build_name: &str,
13234        new_name: &str,
13235        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
13236        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13237    ) -> Result<Epoch> {
13238        use crate::wal::DdlOp;
13239
13240        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13241            || new_name.is_empty()
13242            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13243        {
13244            return Err(MongrelError::InvalidArgument(
13245                "invalid rebuilding-table publish identity".into(),
13246            ));
13247        }
13248        if materialized_view.as_ref().is_some_and(|definition| {
13249            definition.name != new_name || definition.query.trim().is_empty()
13250        }) {
13251            return Err(MongrelError::InvalidArgument(
13252                "invalid materialized-view replacement".into(),
13253            ));
13254        }
13255        self.require(&crate::auth::Permission::Ddl)?;
13256        if self.poisoned.load(Ordering::Relaxed) {
13257            return Err(MongrelError::Other(
13258                "database poisoned by fsync error".into(),
13259            ));
13260        }
13261        // S1A-004: admit the DDL as one core operation.
13262        let _operation = self.admit_operation()?;
13263        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13264        let _ddl = self.ddl_lock.lock();
13265        let _security_write = self.security_write()?;
13266        let (table_id, replaced_table_id) = {
13267            let catalog = self.catalog.read();
13268            let build = catalog.building(build_name).ok_or_else(|| {
13269                MongrelError::NotFound(format!("building table {build_name:?} not found"))
13270            })?;
13271            let replaced_table_id = match &build.state {
13272                TableState::Building {
13273                    intended_name,
13274                    replaces_table_id: Some(replaced_table_id),
13275                    ..
13276                } if intended_name == new_name => *replaced_table_id,
13277                _ => {
13278                    return Err(MongrelError::InvalidArgument(format!(
13279                        "building table {build_name:?} is not a replacement for {new_name:?}"
13280                    )))
13281                }
13282            };
13283            if catalog
13284                .live(new_name)
13285                .is_none_or(|entry| entry.table_id != replaced_table_id)
13286            {
13287                return Err(MongrelError::Conflict(format!(
13288                    "table {new_name:?} changed while its replacement was built"
13289                )));
13290            }
13291            (build.table_id, replaced_table_id)
13292        };
13293
13294        let _commit = self.commit_lock.lock();
13295        let epoch = self.epoch.assigned().next();
13296        let txn_id = self.alloc_txn_id()?;
13297        let mut next_catalog = self.catalog.read().clone();
13298        apply_rebuilding_publish(
13299            &mut next_catalog,
13300            table_id,
13301            replaced_table_id,
13302            new_name,
13303            epoch.0,
13304        )?;
13305        if let Some(definition) = materialized_view.as_mut() {
13306            definition.last_refresh_epoch = epoch.0;
13307        }
13308        let materialized_view_json = materialized_view
13309            .as_ref()
13310            .map(DdlOp::encode_materialized_view)
13311            .transpose()?;
13312        if let Some(definition) = materialized_view {
13313            if let Some(existing) = next_catalog
13314                .materialized_views
13315                .iter_mut()
13316                .find(|existing| existing.name == definition.name)
13317            {
13318                *existing = definition;
13319            } else {
13320                next_catalog.materialized_views.push(definition);
13321            }
13322        }
13323        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13324        if let Some(before_commit) = before_commit {
13325            before_commit()?;
13326        }
13327        let assigned_epoch = self.epoch.bump_assigned();
13328        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
13329        if assigned_epoch != epoch {
13330            return Err(MongrelError::Conflict(
13331                "commit epoch changed while sequencer lock was held".into(),
13332            ));
13333        }
13334        let commit_seq = {
13335            let mut wal = self.shared_wal.lock();
13336            let append: Result<u64> = (|| {
13337                wal.append(
13338                    txn_id,
13339                    table_id,
13340                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
13341                        table_id,
13342                        replaced_table_id,
13343                        new_name: new_name.to_string(),
13344                    }),
13345                )?;
13346                if let Some(definition_json) = materialized_view_json {
13347                    wal.append(
13348                        txn_id,
13349                        table_id,
13350                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
13351                            name: new_name.to_string(),
13352                            definition_json,
13353                        }),
13354                    )?;
13355                }
13356                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13357                wal.append_commit(txn_id, epoch, &[])
13358            })();
13359            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13360        };
13361        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13362
13363        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
13364        self.tables.write().remove(&replaced_table_id);
13365        if let Some(table) = self.tables.read().get(&table_id) {
13366            table.lock().set_catalog_name(new_name.to_string());
13367        }
13368        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
13369        Ok(epoch)
13370    }
13371
13372    fn rename_table_with_state(
13373        &self,
13374        name: &str,
13375        new_name: &str,
13376        building: bool,
13377        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
13378        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13379    ) -> Result<Epoch> {
13380        use crate::wal::DdlOp;
13381        use std::sync::atomic::Ordering;
13382
13383        self.require(&crate::auth::Permission::Ddl)?;
13384        if self.poisoned.load(Ordering::Relaxed) {
13385            return Err(MongrelError::Other(
13386                "database poisoned by fsync error".into(),
13387            ));
13388        }
13389
13390        // A no-op rename short-circuits before any locking, so it can never
13391        // trip the "target already exists" check (the source *is* that name).
13392        if name == new_name {
13393            return Ok(self.visible_epoch());
13394        }
13395        if new_name.is_empty() {
13396            return Err(MongrelError::InvalidArgument(
13397                "rename_table: new name must not be empty".into(),
13398            ));
13399        }
13400        // S1A-004: admit the DDL as one core operation.
13401        let _operation = self.admit_operation()?;
13402        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13403        let _g = self.ddl_lock.lock();
13404        let _security_write = self.security_write()?;
13405        self.require(&crate::auth::Permission::Ddl)?;
13406        let table_id = {
13407            let cat = self.catalog.read();
13408            let src = if building {
13409                cat.building(name)
13410            } else {
13411                cat.live(name)
13412            }
13413            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13414            if building
13415                && !matches!(
13416                    &src.state,
13417                    TableState::Building { intended_name, .. } if intended_name == new_name
13418                )
13419            {
13420                return Err(MongrelError::InvalidArgument(format!(
13421                    "building table {name:?} is not reserved for {new_name:?}"
13422                )));
13423            }
13424            // Target must be free. Checked under ddl_lock, which every other
13425            // DDL (create/rename/drop) also holds, so a concurrent operation
13426            // cannot claim `new_name` between this check and the catalog write.
13427            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
13428                return Err(MongrelError::InvalidArgument(format!(
13429                    "rename_table: a table named {new_name:?} already exists"
13430                )));
13431            }
13432            src.table_id
13433        };
13434
13435        let commit_lock = Arc::clone(&self.commit_lock);
13436        let _c = commit_lock.lock();
13437        let epoch = self.epoch.bump_assigned();
13438        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13439        let txn_id = self.alloc_txn_id()?;
13440        if let Some(definition) = materialized_view.as_mut() {
13441            definition.last_refresh_epoch = epoch.0;
13442        }
13443        let materialized_view_json = materialized_view
13444            .as_ref()
13445            .map(DdlOp::encode_materialized_view)
13446            .transpose()?;
13447        let mut next_catalog = self.catalog.read().clone();
13448        if building {
13449            // CTAS building-table publishes stay on the legacy mutation: the
13450            // command model's `RenameTable` resolves live tables only.
13451            let entry = next_catalog
13452                .tables
13453                .iter_mut()
13454                .find(|t| t.table_id == table_id)
13455                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13456            entry.name = new_name.to_string();
13457            entry.state = TableState::Live;
13458            for trigger in &mut next_catalog.triggers {
13459                if matches!(
13460                    &trigger.trigger.target,
13461                    TriggerTarget::Table(target) if target == name
13462                ) {
13463                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
13464                }
13465            }
13466            if let Some(definition) = next_catalog
13467                .materialized_views
13468                .iter_mut()
13469                .find(|definition| definition.name == name)
13470            {
13471                definition.name = new_name.to_string();
13472            }
13473            if let Some(definition) = materialized_view.take() {
13474                next_catalog.materialized_views.push(definition);
13475            }
13476            for table in &mut next_catalog.security.rls_tables {
13477                if table == name {
13478                    *table = new_name.to_string();
13479                }
13480            }
13481            for policy in &mut next_catalog.security.policies {
13482                if policy.table == name {
13483                    policy.table = new_name.to_string();
13484                }
13485            }
13486            for mask in &mut next_catalog.security.masks {
13487                if mask.table == name {
13488                    mask.table = new_name.to_string();
13489                }
13490            }
13491            for role in &mut next_catalog.roles {
13492                for permission in &mut role.permissions {
13493                    rename_permission_table(permission, name, new_name);
13494                }
13495            }
13496            advance_security_version(&mut next_catalog)?;
13497        } else {
13498            // S1F-001: a plain live-table rename is a versioned catalog
13499            // command (the delta mirrors the legacy mutation above, including
13500            // trigger retargets and table-scoped security state).
13501            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
13502                name: name.to_string(),
13503                new_name: new_name.to_string(),
13504                at_epoch: epoch.0,
13505            };
13506            self.apply_catalog_command_to(&mut next_catalog, command)?;
13507        }
13508        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13509        let ddl = if building {
13510            DdlOp::PublishBuildingTable {
13511                table_id,
13512                new_name: new_name.to_string(),
13513            }
13514        } else {
13515            DdlOp::RenameTable {
13516                table_id,
13517                new_name: new_name.to_string(),
13518            }
13519        };
13520        let commit_seq = {
13521            let mut wal = self.shared_wal.lock();
13522            if let Some(before_commit) = before_commit {
13523                before_commit()?;
13524            }
13525            let append: Result<u64> = (|| {
13526                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
13527                if let Some(definition_json) = materialized_view_json {
13528                    wal.append(
13529                        txn_id,
13530                        table_id,
13531                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
13532                            name: new_name.to_string(),
13533                            definition_json,
13534                        }),
13535                    )?;
13536                }
13537                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13538                wal.append_commit(txn_id, epoch, &[])
13539            })();
13540            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13541        };
13542        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13543
13544        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
13545        // The in-memory table object is keyed by table_id, not name, so it does
13546        // not move and live TableHandles remain valid.
13547        if let Some(table) = self.tables.read().get(&table_id) {
13548            table.lock().set_catalog_name(new_name.to_string());
13549        }
13550        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
13551        Ok(epoch)
13552    }
13553
13554    pub fn alter_column(
13555        &self,
13556        table_name: &str,
13557        column_name: &str,
13558        change: AlterColumn,
13559    ) -> Result<ColumnDef> {
13560        self.alter_column_with_epoch(table_name, column_name, change)
13561            .map(|(column, _)| column)
13562    }
13563
13564    pub fn alter_column_with_epoch(
13565        &self,
13566        table_name: &str,
13567        column_name: &str,
13568        change: AlterColumn,
13569    ) -> Result<(ColumnDef, Option<Epoch>)> {
13570        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
13571    }
13572
13573    /// Cooperatively prepare an ALTER and fence each durable commit separately.
13574    /// `after_commit(Some(epoch))` follows an exact durable outcome;
13575    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
13576    /// for every successful `before_commit` callback.
13577    pub fn alter_column_with_epoch_controlled<B, A>(
13578        &self,
13579        table_name: &str,
13580        column_name: &str,
13581        change: AlterColumn,
13582        control: &crate::ExecutionControl,
13583        mut before_commit: B,
13584        mut after_commit: A,
13585    ) -> Result<(ColumnDef, Option<Epoch>)>
13586    where
13587        B: FnMut() -> Result<()>,
13588        A: FnMut(Option<Epoch>) -> Result<()>,
13589    {
13590        self.alter_column_with_epoch_inner(
13591            table_name,
13592            column_name,
13593            change,
13594            Some(control),
13595            Some(&mut before_commit),
13596            Some(&mut after_commit),
13597        )
13598    }
13599
13600    #[allow(clippy::too_many_arguments)]
13601    fn alter_column_with_epoch_inner(
13602        &self,
13603        table_name: &str,
13604        column_name: &str,
13605        change: AlterColumn,
13606        control: Option<&crate::ExecutionControl>,
13607        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13608        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
13609    ) -> Result<(ColumnDef, Option<Epoch>)> {
13610        use crate::wal::DdlOp;
13611        use std::sync::atomic::Ordering;
13612
13613        self.require(&crate::auth::Permission::Ddl)?;
13614        commit_prepare_checkpoint(control, 0)?;
13615        if self.poisoned.load(Ordering::Relaxed) {
13616            return Err(MongrelError::Other(
13617                "database poisoned by fsync error".into(),
13618            ));
13619        }
13620        // S1A-004: admit the DDL as one core operation.
13621        let _operation = self.admit_operation()?;
13622        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13623        let _g = self.ddl_lock.lock();
13624        let table_id = {
13625            let cat = self.catalog.read();
13626            cat.live(table_name)
13627                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
13628                .table_id
13629        };
13630        let handle =
13631            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
13632                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
13633            })?;
13634
13635        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
13636        // declared default, backfill existing NULL/absent cells as one durable
13637        // transaction before logging the metadata change. A crash between the
13638        // two commits leaves a harmless nullable-but-filled column; retry is
13639        // idempotent because only remaining NULLs are touched.
13640        let backfill = {
13641            let table = handle.lock();
13642            let old = table
13643                .schema()
13644                .column(column_name)
13645                .cloned()
13646                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
13647            let next_flags = change.flags.unwrap_or(old.flags);
13648            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
13649                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
13650                && old.default_value.is_some()
13651            {
13652                let snapshot = Snapshot::at(self.epoch.visible());
13653                let mut updates = Vec::new();
13654                let rows = match control {
13655                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
13656                    None => table.visible_rows(snapshot)?,
13657                };
13658                for (row_index, row) in rows.into_iter().enumerate() {
13659                    commit_prepare_checkpoint(control, row_index)?;
13660                    if row
13661                        .columns
13662                        .get(&old.id)
13663                        .is_some_and(|value| !matches!(value, Value::Null))
13664                    {
13665                        continue;
13666                    }
13667                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
13668                    table.apply_defaults(&mut cells)?;
13669                    updates.push((
13670                        table_id,
13671                        crate::txn::Staged::Update {
13672                            row_id: row.row_id,
13673                            new_row: cells,
13674                            changed_columns: vec![old.id],
13675                        },
13676                    ));
13677                }
13678                updates
13679            } else {
13680                Vec::new()
13681            }
13682        };
13683        let durable_epoch = std::cell::Cell::new(None);
13684        let backfill_epoch = if backfill.is_empty() {
13685            None
13686        } else {
13687            let (principal, catalog_bound) = self.transaction_principal_snapshot();
13688            let txn_id = self.alloc_txn_id()?;
13689            let mut entered_fence = false;
13690            let commit_result = match (control, before_commit.as_deref_mut()) {
13691                (Some(control), Some(before_commit)) => self
13692                    .commit_transaction_with_external_states_controlled(
13693                        txn_id,
13694                        self.epoch.visible(),
13695                        backfill,
13696                        Vec::new(),
13697                        Vec::new(),
13698                        principal.clone(),
13699                        catalog_bound,
13700                        None,
13701                        crate::txn::TxnCommitContext::internal(),
13702                        control,
13703                        &mut || {
13704                            before_commit()?;
13705                            entered_fence = true;
13706                            Ok(())
13707                        },
13708                    )
13709                    .map(|(epoch, _)| epoch),
13710                _ => self
13711                    .commit_transaction_with_external_states(
13712                        txn_id,
13713                        self.epoch.visible(),
13714                        backfill,
13715                        Vec::new(),
13716                        Vec::new(),
13717                        principal,
13718                        catalog_bound,
13719                        None,
13720                        crate::txn::TxnCommitContext::internal(),
13721                    )
13722                    .map(|(epoch, _)| epoch),
13723            };
13724            let commit_result = if entered_fence {
13725                finish_controlled_commit_attempt(commit_result, &mut after_commit)
13726            } else {
13727                commit_result
13728            };
13729            match &commit_result {
13730                Ok(epoch) => durable_epoch.set(Some(*epoch)),
13731                Err(MongrelError::DurableCommit { epoch, .. }) => {
13732                    durable_epoch.set(Some(Epoch(*epoch)));
13733                }
13734                Err(_) => {}
13735            }
13736            Some(commit_result?)
13737        };
13738        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
13739            let _security_write = self.security_write()?;
13740            self.require(&crate::auth::Permission::Ddl)?;
13741            if self
13742                .catalog
13743                .read()
13744                .live(table_name)
13745                .is_none_or(|entry| entry.table_id != table_id)
13746            {
13747                return Err(MongrelError::Conflict(format!(
13748                    "table {table_name:?} changed during ALTER"
13749                )));
13750            }
13751            let mut table = handle.lock();
13752            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
13753            let renamed_column = (column.name != column_name).then(|| column.name.clone());
13754            let Some(prepared_schema) = prepared_schema else {
13755                return Ok((column, backfill_epoch));
13756            };
13757            // S1F-001: the schema mutation is a versioned catalog command. It
13758            // validates pure against the current catalog before an epoch is
13759            // consumed; the engine's post-apply schema (schema_id bump
13760            // included) is the resolved delta the wrapper publishes.
13761            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
13762                table: table_name.to_string(),
13763                column: column.clone(),
13764            };
13765            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
13766
13767            let commit_lock = Arc::clone(&self.commit_lock);
13768            let _c = commit_lock.lock();
13769            let epoch = self.epoch.bump_assigned();
13770            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13771            let txn_id = self.alloc_txn_id()?;
13772            let column_json = DdlOp::encode_column(&column)?;
13773            let mut next_catalog = self.catalog.read().clone();
13774            let catalog_entry_index = next_catalog
13775                .tables
13776                .iter()
13777                .position(|entry| entry.table_id == table_id)
13778                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
13779            if let Some(new_column_name) = &renamed_column {
13780                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
13781                    commit_prepare_checkpoint(control, trigger_index)?;
13782                    if matches!(
13783                        &trigger.trigger.target,
13784                        TriggerTarget::Table(target) if target == table_name
13785                    ) {
13786                        trigger.trigger = trigger.trigger.renamed_update_column(
13787                            column_name,
13788                            new_column_name.clone(),
13789                            epoch.0,
13790                        )?;
13791                    }
13792                }
13793                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
13794                    commit_prepare_checkpoint(control, role_index)?;
13795                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
13796                        commit_prepare_checkpoint(control, permission_index)?;
13797                        rename_permission_column(
13798                            permission,
13799                            table_name,
13800                            column_name,
13801                            new_column_name,
13802                        );
13803                    }
13804                }
13805                advance_security_version(&mut next_catalog)?;
13806            }
13807            // Record the versioned command (validating again against the
13808            // candidate), then install the engine-resolved schema image:
13809            // identical to the command's delta when the mounted table and the
13810            // catalog are in sync, and byte-for-byte what the legacy inline
13811            // mutation published either way.
13812            self.apply_catalog_command_to(&mut next_catalog, command)?;
13813            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
13814            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13815            commit_prepare_checkpoint(control, 0)?;
13816            let mut entered_fence = false;
13817            if let Some(before_commit) = before_commit.as_deref_mut() {
13818                before_commit()?;
13819                entered_fence = true;
13820            }
13821            let commit_result: Result<Epoch> = (|| {
13822                let commit_seq = {
13823                    let mut wal = self.shared_wal.lock();
13824                    let append: Result<u64> = (|| {
13825                        wal.append(
13826                            txn_id,
13827                            table_id,
13828                            crate::wal::Op::Ddl(DdlOp::AlterTable {
13829                                table_id,
13830                                column_json,
13831                            }),
13832                        )?;
13833                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13834                        wal.append_commit(txn_id, epoch, &[])
13835                    })();
13836                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13837                };
13838                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13839                durable_epoch.set(Some(epoch));
13840
13841                table.apply_altered_schema_prepared(prepared_schema);
13842                let schema = table.schema().clone();
13843                let table_checkpoint = table.checkpoint_altered_schema();
13844                drop(table);
13845                next_catalog.tables[catalog_entry_index].schema = schema;
13846                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13847                let catalog_result =
13848                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
13849                let security_version = next_catalog.security_version;
13850                *self.catalog.write() = next_catalog;
13851                if renamed_column.is_some() {
13852                    self.security_coordinator
13853                        .version
13854                        .store(security_version, Ordering::Release);
13855                }
13856                self.publish_committed(&receipt, epoch)?;
13857                _epoch_guard.disarm();
13858                if let Err(error) = table_checkpoint.and(catalog_result) {
13859                    self.poisoned.store(true, Ordering::Relaxed);
13860                    self.lifecycle.poison();
13861                    return Err(MongrelError::DurableCommit {
13862                        epoch: epoch.0,
13863                        message: error.to_string(),
13864                    });
13865                }
13866                Ok(epoch)
13867            })();
13868            let commit_result = if entered_fence {
13869                finish_controlled_commit_attempt(commit_result, &mut after_commit)
13870            } else {
13871                commit_result
13872            };
13873            let epoch = commit_result?;
13874            Ok((column, Some(epoch)))
13875        })();
13876        result.map_err(|error| match (durable_epoch.get(), error) {
13877            (_, error @ MongrelError::DurableCommit { .. }) => error,
13878            (Some(epoch), error) => MongrelError::DurableCommit {
13879                epoch: epoch.0,
13880                message: error.to_string(),
13881            },
13882            (None, error) => error,
13883        })
13884    }
13885
13886    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
13887    /// replication. Duration is in nanoseconds.
13888    pub fn set_table_ttl(
13889        &self,
13890        table_name: &str,
13891        column_name: &str,
13892        duration_nanos: u64,
13893    ) -> Result<crate::manifest::TtlPolicy> {
13894        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
13895        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
13896    }
13897
13898    /// Set TTL metadata on a hidden build before it is published.
13899    #[doc(hidden)]
13900    pub fn set_building_table_ttl(
13901        &self,
13902        table_name: &str,
13903        column_name: &str,
13904        duration_nanos: u64,
13905    ) -> Result<crate::manifest::TtlPolicy> {
13906        let policy = self.replace_table_ttl_with_state(
13907            table_name,
13908            Some((column_name, duration_nanos)),
13909            true,
13910        )?;
13911        policy
13912            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
13913    }
13914
13915    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
13916        self.replace_table_ttl(table_name, None)?;
13917        Ok(())
13918    }
13919
13920    fn replace_table_ttl(
13921        &self,
13922        table_name: &str,
13923        requested: Option<(&str, u64)>,
13924    ) -> Result<Option<crate::manifest::TtlPolicy>> {
13925        self.replace_table_ttl_with_state(table_name, requested, false)
13926    }
13927
13928    fn replace_table_ttl_with_state(
13929        &self,
13930        table_name: &str,
13931        requested: Option<(&str, u64)>,
13932        building: bool,
13933    ) -> Result<Option<crate::manifest::TtlPolicy>> {
13934        use crate::wal::DdlOp;
13935        use std::sync::atomic::Ordering;
13936
13937        self.require(&crate::auth::Permission::Ddl)?;
13938        if self.poisoned.load(Ordering::Relaxed) {
13939            return Err(MongrelError::Other(
13940                "database poisoned by fsync error".into(),
13941            ));
13942        }
13943
13944        let _g = self.ddl_lock.lock();
13945        let _security_write = self.security_write()?;
13946        self.require(&crate::auth::Permission::Ddl)?;
13947        let table_id = {
13948            let cat = self.catalog.read();
13949            if building {
13950                cat.building(table_name)
13951            } else {
13952                cat.live(table_name)
13953            }
13954            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
13955            .table_id
13956        };
13957        let handle =
13958            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
13959                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
13960            })?;
13961        let mut table = handle.lock();
13962        let policy = match requested {
13963            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
13964            None => None,
13965        };
13966        if table.ttl() == policy {
13967            return Ok(policy);
13968        }
13969
13970        let commit_lock = Arc::clone(&self.commit_lock);
13971        let _c = commit_lock.lock();
13972        let epoch = self.epoch.bump_assigned();
13973        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13974        let txn_id = self.alloc_txn_id()?;
13975        let policy_json = DdlOp::encode_ttl(policy)?;
13976        let mut next_catalog = self.catalog.read().clone();
13977        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13978        let commit_seq = {
13979            let mut wal = self.shared_wal.lock();
13980            let append: Result<u64> = (|| {
13981                wal.append(
13982                    txn_id,
13983                    table_id,
13984                    crate::wal::Op::Ddl(DdlOp::SetTtl {
13985                        table_id,
13986                        policy_json,
13987                    }),
13988                )?;
13989                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13990                wal.append_commit(txn_id, epoch, &[])
13991            })();
13992            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13993        };
13994        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13995
13996        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
13997        drop(table);
13998        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
13999            publish_error.get_or_insert(error);
14000        }
14001        self.finish_durable_publish(
14002            epoch,
14003            &mut _epoch_guard,
14004            &receipt,
14005            publish_error.map_or(Ok(()), Err),
14006        )?;
14007        Ok(policy)
14008    }
14009
14010    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
14011    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
14012    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
14013    ///
14014    /// Returns the number of items reclaimed.
14015    pub fn gc(&self) -> Result<usize> {
14016        let control = crate::ExecutionControl::new(None);
14017        self.gc_controlled(&control, || true)
14018    }
14019
14020    /// Discover reclaimable state cooperatively, then cross one publication
14021    /// boundary immediately before the first irreversible deletion.
14022    #[doc(hidden)]
14023    pub fn gc_controlled<F>(
14024        &self,
14025        control: &crate::ExecutionControl,
14026        before_publish: F,
14027    ) -> Result<usize>
14028    where
14029        F: FnOnce() -> bool,
14030    {
14031        self.gc_controlled_with_receipt(control, before_publish)
14032            .map(|(reclaimed, _)| reclaimed)
14033    }
14034
14035    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
14036    /// return that snapshot if an irreversible deletion was attempted.
14037    #[doc(hidden)]
14038    pub fn gc_controlled_with_receipt<F>(
14039        &self,
14040        control: &crate::ExecutionControl,
14041        before_publish: F,
14042    ) -> Result<(usize, Option<MaintenanceReceipt>)>
14043    where
14044        F: FnOnce() -> bool,
14045    {
14046        enum Candidate {
14047            Directory(PathBuf),
14048            File(PathBuf),
14049        }
14050
14051        self.require(&crate::auth::Permission::Ddl)?;
14052        // S1A-004: admit the maintenance pass as one core operation.
14053        let _operation = self.admit_operation()?;
14054        let _ddl = self.ddl_lock.lock();
14055        self.require(&crate::auth::Permission::Ddl)?;
14056        control.checkpoint()?;
14057        let maintenance_epoch = self.epoch.visible();
14058        let min_active = self.snapshots.min_active(maintenance_epoch).0;
14059        let mut candidates = Vec::new();
14060
14061        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
14062        let cat = self.catalog.read();
14063        for (entry_index, entry) in cat.tables.iter().enumerate() {
14064            if entry_index % 256 == 0 {
14065                control.checkpoint()?;
14066            }
14067            if let TableState::Dropped { at_epoch } = entry.state {
14068                if at_epoch <= min_active {
14069                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
14070                    if tdir.exists() {
14071                        candidates.push(Candidate::Directory(tdir));
14072                    }
14073                }
14074            }
14075        }
14076        drop(cat);
14077
14078        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
14079        // in-flight spill's dir (deleting it would lose the pending run and fail
14080        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
14081        // skip any id still registered in `active_spills`.
14082        let cat = self.catalog.read();
14083        for (entry_index, entry) in cat.tables.iter().enumerate() {
14084            if entry_index % 256 == 0 {
14085                control.checkpoint()?;
14086            }
14087            if !matches!(entry.state, TableState::Live) {
14088                continue;
14089            }
14090            let txn_dir = self
14091                .root
14092                .join(TABLES_DIR)
14093                .join(entry.table_id.to_string())
14094                .join("_txn");
14095            if !txn_dir.exists() {
14096                continue;
14097            }
14098            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
14099                if sub_index % 256 == 0 {
14100                    control.checkpoint()?;
14101                }
14102                let sub = sub?;
14103                let name = sub.file_name();
14104                let Some(name) = name.to_str() else { continue };
14105                // A non-numeric entry can't belong to a live txn — sweep it.
14106                let is_active = name
14107                    .parse::<u64>()
14108                    .map(|id| self.active_spills.is_active(id))
14109                    .unwrap_or(false);
14110                if is_active {
14111                    continue;
14112                }
14113                candidates.push(Candidate::Directory(sub.path()));
14114            }
14115        }
14116        drop(cat);
14117
14118        let external_names = {
14119            let cat = self.catalog.read();
14120            cat.external_tables
14121                .iter()
14122                .map(|entry| entry.name.clone())
14123                .collect::<std::collections::HashSet<_>>()
14124        };
14125        let vtab_dir = self.root.join(VTAB_DIR);
14126        if vtab_dir.exists() {
14127            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
14128                if entry_index % 256 == 0 {
14129                    control.checkpoint()?;
14130                }
14131                let entry = entry?;
14132                let name = entry.file_name();
14133                let Some(name) = name.to_str() else { continue };
14134                if external_names.contains(name) {
14135                    continue;
14136                }
14137                let path = entry.path();
14138                if path.is_dir() {
14139                    candidates.push(Candidate::Directory(path));
14140                } else {
14141                    candidates.push(Candidate::File(path));
14142                }
14143            }
14144        }
14145
14146        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
14147        // can still need (spec §6.4). Each table deletes its own retired files
14148        // gated on `min_active` and persists its manifest.
14149        let tables = self
14150            .tables
14151            .read()
14152            .iter()
14153            .map(|(table_id, handle)| (*table_id, handle.clone()))
14154            .collect::<Vec<_>>();
14155        let mut retiring = Vec::new();
14156        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
14157            if table_index % 256 == 0 {
14158                control.checkpoint()?;
14159            }
14160            let backup_pinned: HashSet<u128> = self
14161                .backup_pins
14162                .lock()
14163                .keys()
14164                .filter_map(|(pinned_table, run_id)| {
14165                    (*pinned_table == *table_id).then_some(*run_id)
14166                })
14167                .collect();
14168            if handle
14169                .lock()
14170                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
14171            {
14172                retiring.push((handle.clone(), backup_pinned));
14173            }
14174        }
14175
14176        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
14177        // segment on every reopen without truncating the prior ones, so rotated
14178        // segments accumulate. Once every live table's committed data is durable
14179        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
14180        // (non-active) segments are redundant for recovery and safe to delete —
14181        // an in-flight txn only ever appends to the active segment, which is
14182        // never deleted.
14183        let all_durable = self.active_spills.is_idle()
14184            && tables.iter().all(|(_, handle)| {
14185                let g = handle.lock();
14186                g.memtable_len() == 0 && g.mutable_run_len() == 0
14187            });
14188        let retain = self
14189            .replication_wal_retention_segments
14190            .load(std::sync::atomic::Ordering::Relaxed);
14191        let reap_wal = all_durable
14192            && self
14193                .shared_wal
14194                .lock()
14195                .has_gc_segments_retain_recent(retain)?;
14196
14197        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
14198            return Ok((0, None));
14199        }
14200        control.checkpoint()?;
14201        if !before_publish() {
14202            return Err(MongrelError::Cancelled);
14203        }
14204
14205        let mut reclaimed = 0;
14206        for candidate in candidates {
14207            match candidate {
14208                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
14209                Candidate::File(path) => std::fs::remove_file(path)?,
14210            }
14211            reclaimed += 1;
14212        }
14213        for (handle, backup_pinned) in retiring {
14214            reclaimed += handle
14215                .lock()
14216                .reap_retiring(Epoch(min_active), &backup_pinned)?;
14217        }
14218        if reap_wal {
14219            reclaimed += self
14220                .shared_wal
14221                .lock()
14222                .gc_segments_retain_recent(u64::MAX, retain)?;
14223        }
14224
14225        Ok((
14226            reclaimed,
14227            Some(MaintenanceReceipt {
14228                epoch: maintenance_epoch,
14229            }),
14230        ))
14231    }
14232
14233    /// Produce a deterministic-stable byte image of the database directory.
14234    ///
14235    /// After `checkpoint()`:
14236    ///   - All pending writes are flushed to sorted runs (no memtable data).
14237    ///   - Each table is compacted to a single sorted run (no run fragmentation).
14238    ///   - All non-active WAL segments are deleted (data is durable in runs).
14239    ///   - The active WAL segment is rotated to a fresh empty segment.
14240    ///   - Dropped-table directories are removed.
14241    ///   - All manifests + catalog are persisted.
14242    ///
14243    /// The resulting directory is byte-stable: `git add` captures a snapshot
14244    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
14245    /// no unbounded segment growth, no mutable-run spill files.
14246    ///
14247    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
14248    /// It does NOT clear the exclusive lock — the caller still owns the
14249    /// database handle.
14250    pub fn checkpoint(&self) -> Result<()> {
14251        self.checkpoint_controlled(|| Ok(()))
14252    }
14253
14254    /// Strict checkpoint with a deterministic test hook after every table is
14255    /// flushed/compacted but before WAL replacement.
14256    #[doc(hidden)]
14257    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
14258    where
14259        F: FnOnce() -> Result<()>,
14260    {
14261        self.require(&crate::auth::Permission::Ddl)?;
14262        // S1A-004: admit the checkpoint as one core operation.
14263        let _operation = self.admit_operation()?;
14264        // Block cross-table commits and DDL for the full operation. Locking all
14265        // mounted handles also excludes direct `Table` commits, which do not
14266        // enter the database replication barrier.
14267        let _replication = self.replication_barrier.write();
14268        let _ddl = self.ddl_lock.lock();
14269        let _security = self.security_coordinator.gate.read();
14270        self.require(&crate::auth::Permission::Ddl)?;
14271
14272        let mut handles = self
14273            .tables
14274            .read()
14275            .iter()
14276            .map(|(table_id, handle)| (*table_id, handle.clone()))
14277            .collect::<Vec<_>>();
14278        handles.sort_by_key(|(table_id, _)| *table_id);
14279        let mut tables = handles
14280            .iter()
14281            .map(|(table_id, handle)| (*table_id, handle.lock()))
14282            .collect::<Vec<_>>();
14283
14284        // Strict flush. Any error leaves the old WAL recovery source intact.
14285        for (_, table) in &mut tables {
14286            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
14287            {
14288                table.force_flush()?;
14289            }
14290        }
14291
14292        // Strict compaction. Checkpoint never reports a stable image after a
14293        // skipped failure.
14294        for (_, table) in &mut tables {
14295            if table.run_count() >= 2 || table.should_compact() {
14296                table.compact()?;
14297            }
14298        }
14299
14300        before_wal_reset()?;
14301
14302        // Reap table-local retired runs while every table remains quiesced.
14303        let maintenance_epoch = self.epoch.visible();
14304        let min_active = self.snapshots.min_active(maintenance_epoch);
14305        for (table_id, table) in &mut tables {
14306            let backup_pinned: HashSet<u128> = self
14307                .backup_pins
14308                .lock()
14309                .keys()
14310                .filter_map(|(pinned_table, run_id)| {
14311                    (*pinned_table == *table_id).then_some(*run_id)
14312                })
14313                .collect();
14314            table.reap_retiring(min_active, &backup_pinned)?;
14315        }
14316
14317        // Publish a fresh synced active WAL, then durably reap every older
14318        // segment. This point is reached only after every strict flush succeeds.
14319        self.shared_wal.lock().reset_after_checkpoint()?;
14320
14321        // Remove catalog-unreachable directories and stale transaction state.
14322        let catalog_snapshot = self.catalog.read().clone();
14323        for entry in &catalog_snapshot.tables {
14324            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
14325                crate::durable_file::remove_directory_all(
14326                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
14327                )?;
14328            }
14329            if !matches!(entry.state, TableState::Live) {
14330                continue;
14331            }
14332            let transaction_dir = self
14333                .root
14334                .join(TABLES_DIR)
14335                .join(entry.table_id.to_string())
14336                .join("_txn");
14337            if transaction_dir.is_dir() {
14338                for child in std::fs::read_dir(&transaction_dir)? {
14339                    let child = child?;
14340                    let active = child
14341                        .file_name()
14342                        .to_str()
14343                        .and_then(|name| name.parse::<u64>().ok())
14344                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
14345                    if !active {
14346                        crate::durable_file::remove_directory_all(&child.path())?;
14347                    }
14348                }
14349            }
14350        }
14351        let external_names = catalog_snapshot
14352            .external_tables
14353            .iter()
14354            .map(|entry| entry.name.as_str())
14355            .collect::<HashSet<_>>();
14356        let external_root = self.root.join(VTAB_DIR);
14357        if external_root.is_dir() {
14358            for entry in std::fs::read_dir(&external_root)? {
14359                let entry = entry?;
14360                let name = entry.file_name();
14361                if name
14362                    .to_str()
14363                    .is_some_and(|name| external_names.contains(name))
14364                {
14365                    continue;
14366                }
14367                if entry.file_type()?.is_dir() {
14368                    crate::durable_file::remove_directory_all(&entry.path())?;
14369                } else {
14370                    std::fs::remove_file(entry.path())?;
14371                    crate::durable_file::sync_directory(&external_root)?;
14372                }
14373            }
14374        }
14375
14376        // Final authoritative metadata checkpoint while all writers remain
14377        // excluded.
14378        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
14379        let visible = self.epoch.visible();
14380        for (_, table) in &tables {
14381            table.persist_manifest(visible)?;
14382        }
14383
14384        Ok(())
14385    }
14386    fn alloc_txn_id(&self) -> Result<u64> {
14387        self.ensure_owner_process()?;
14388        crate::txn::allocate_txn_id(&self.next_txn_id)
14389    }
14390
14391    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
14392    /// (or other multi-statement lock holds). Released via
14393    /// [`Self::release_txn_locks`].
14394    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
14395        self.alloc_txn_id()
14396    }
14397
14398    /// Set the per-table spill threshold (bytes). When a transaction's staged
14399    /// bytes for a single table exceed this, the rows are written as a
14400    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
14401    pub fn set_spill_threshold(&self, bytes: u64) {
14402        self.spill_threshold
14403            .store(bytes, std::sync::atomic::Ordering::Relaxed);
14404    }
14405
14406    /// Test-only: install a hook invoked after a transaction writes its spill
14407    /// runs but before the sequencer, so a test can race `gc()` against an
14408    /// in-flight spill. Not part of the stable API.
14409    #[doc(hidden)]
14410    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14411        *self.spill_hook.lock() = Some(Box::new(f));
14412    }
14413
14414    /// Test-only: install a hook invoked while a spilled commit holds the
14415    /// security read gate and before it appends to the WAL.
14416    #[doc(hidden)]
14417    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14418        *self.security_commit_hook.lock() = Some(Box::new(f));
14419    }
14420
14421    /// Test-only: install a hook after transaction preparation and before the
14422    /// commit sequencer validates catalog generations.
14423    #[doc(hidden)]
14424    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14425        *self.catalog_commit_hook.lock() = Some(Box::new(f));
14426    }
14427
14428    /// Test-only: pause an online backup after its consistent boundary is
14429    /// captured but before the pinned immutable runs are copied.
14430    #[doc(hidden)]
14431    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14432        *self.backup_hook.lock() = Some(Box::new(f));
14433    }
14434
14435    /// Test-only: invoked after each successful FK parent-protection lock
14436    /// acquisition during constraint validation, so tests can rendezvous two
14437    /// committing transactions into a deterministic wait-for cycle.
14438    #[doc(hidden)]
14439    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14440        *self.fk_lock_hook.lock() = Some(Arc::new(f));
14441    }
14442
14443    /// Test-only: pause WAL extraction before its final principal recheck.
14444    #[doc(hidden)]
14445    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14446        *self.replication_hook.lock() = Some(Box::new(f));
14447    }
14448
14449    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
14450    /// this stays well below the number of committed transactions when commits
14451    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
14452    #[doc(hidden)]
14453    pub fn __wal_group_sync_count(&self) -> u64 {
14454        self.shared_wal.lock().group_sync_count()
14455    }
14456
14457    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
14458    /// contract that an fsync error would trigger in production.
14459    #[doc(hidden)]
14460    pub fn __poison(&self) {
14461        self.poisoned
14462            .store(true, std::sync::atomic::Ordering::Relaxed);
14463    }
14464
14465    /// Verify multi-table integrity (spec §16). For every live table this:
14466    /// authenticates the manifest; opens each `RunRef`'s file through
14467    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
14468    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
14469    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
14470    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
14471    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
14472    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
14473    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
14474    ///
14475    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
14476    /// full body, so this is an integrity tool, not a hot path.
14477    pub fn check(&self) -> Vec<CheckIssue> {
14478        match self.check_inner(None) {
14479            Ok(issues) => issues,
14480            Err(error) => vec![CheckIssue {
14481                table_id: WAL_TABLE_ID,
14482                table_name: "shared WAL".into(),
14483                severity: "error".into(),
14484                description: error.to_string(),
14485            }],
14486        }
14487    }
14488
14489    /// Integrity check with cooperative cancellation between tables and runs.
14490    #[doc(hidden)]
14491    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
14492        self.check_inner(Some(control))
14493    }
14494
14495    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
14496        let mut issues = Vec::new();
14497        let cat = self.catalog.read();
14498        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
14499        for (table_index, entry) in cat.tables.iter().enumerate() {
14500            if table_index % 256 == 0 {
14501                if let Some(control) = control {
14502                    control.checkpoint()?;
14503                }
14504            }
14505            if !matches!(entry.state, TableState::Live) {
14506                continue;
14507            }
14508            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
14509            let mut err = |sev: &str, desc: String| {
14510                issues.push(CheckIssue {
14511                    table_id: entry.table_id,
14512                    table_name: entry.name.clone(),
14513                    severity: sev.into(),
14514                    description: desc,
14515                });
14516            };
14517            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
14518                Ok(m) => m,
14519                Err(e) => {
14520                    err("error", format!("manifest read failed: {e}"));
14521                    continue;
14522                }
14523            };
14524            if m.flushed_epoch > m.current_epoch {
14525                err(
14526                    "error",
14527                    format!(
14528                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
14529                        m.flushed_epoch, m.current_epoch
14530                    ),
14531                );
14532            }
14533
14534            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
14535            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
14536            for (run_index, rr) in m.runs.iter().enumerate() {
14537                if run_index % 256 == 0 {
14538                    if let Some(control) = control {
14539                        control.checkpoint()?;
14540                    }
14541                }
14542                referenced.insert(rr.run_id);
14543                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
14544                if !run_path.exists() {
14545                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
14546                    continue;
14547                }
14548                match crate::sorted_run::RunReader::open(
14549                    &run_path,
14550                    entry.schema.clone(),
14551                    self.kek.clone(),
14552                ) {
14553                    Ok(reader) => {
14554                        if reader.row_count() as u64 != rr.row_count {
14555                            err(
14556                                "error",
14557                                format!(
14558                                    "run r-{} row count mismatch: manifest {} vs run {}",
14559                                    rr.run_id,
14560                                    rr.row_count,
14561                                    reader.row_count()
14562                                ),
14563                            );
14564                        }
14565                    }
14566                    Err(e) => {
14567                        err(
14568                            "error",
14569                            format!("run r-{} integrity check failed: {e}", rr.run_id),
14570                        );
14571                    }
14572                }
14573            }
14574
14575            // Compaction-superseded runs awaiting retention-gated deletion are
14576            // tracked in `retiring`; their files are expected on disk, so they
14577            // are not orphans.
14578            for r in &m.retiring {
14579                referenced.insert(r.run_id);
14580            }
14581
14582            // Orphan `.sr` files present on disk but absent from the manifest.
14583            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
14584                for (entry_index, ent) in rd.flatten().enumerate() {
14585                    if entry_index % 256 == 0 {
14586                        if let Some(control) = control {
14587                            control.checkpoint()?;
14588                        }
14589                    }
14590                    let p = ent.path();
14591                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
14592                        continue;
14593                    }
14594                    let run_id = p
14595                        .file_stem()
14596                        .and_then(|s| s.to_str())
14597                        .and_then(|s| s.strip_prefix("r-"))
14598                        .and_then(|s| s.parse::<u128>().ok());
14599                    if let Some(id) = run_id {
14600                        if !referenced.contains(&id) {
14601                            err(
14602                                "warning",
14603                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
14604                            );
14605                        }
14606                    }
14607                }
14608            }
14609        }
14610
14611        let external_names = cat
14612            .external_tables
14613            .iter()
14614            .map(|entry| entry.name.clone())
14615            .collect::<std::collections::HashSet<_>>();
14616        let vtab_dir = self.root.join(VTAB_DIR);
14617        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
14618            for (entry_index, entry) in entries.flatten().enumerate() {
14619                if entry_index % 256 == 0 {
14620                    if let Some(control) = control {
14621                        control.checkpoint()?;
14622                    }
14623                }
14624                let name = entry.file_name();
14625                let Some(name) = name.to_str() else { continue };
14626                if !external_names.contains(name) {
14627                    issues.push(CheckIssue {
14628                        table_id: EXTERNAL_TABLE_ID,
14629                        table_name: name.to_string(),
14630                        severity: "warning".into(),
14631                        description: format!(
14632                            "orphan external table state entry {:?} not referenced by the catalog",
14633                            entry.path()
14634                        ),
14635                    });
14636                }
14637            }
14638        }
14639
14640        // WAL retention / integrity invariant (spec §16): every on-disk WAL
14641        // segment must open (header magic + version, and the frame cipher must
14642        // be derivable for an encrypted WAL). A segment that won't open is
14643        // corrupt or truncated and would break crash recovery. `table_id` is
14644        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
14645        // never confuses a WAL issue with a real table.
14646        if let Some(control) = control {
14647            control.checkpoint()?;
14648        }
14649        for (seg, msg) in self.shared_wal.lock().verify_segments() {
14650            issues.push(CheckIssue {
14651                table_id: WAL_TABLE_ID,
14652                table_name: "<wal>".into(),
14653                severity: "error".into(),
14654                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
14655            });
14656        }
14657        Ok(issues)
14658    }
14659
14660    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
14661    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
14662    /// unmounts them from the live table map so the DB still opens.
14663    pub fn doctor(&self) -> Result<Vec<u64>> {
14664        let control = crate::ExecutionControl::new(None);
14665        self.doctor_controlled(&control, || true)
14666    }
14667
14668    /// Check cancellably, then fence immediately before the first quarantine
14669    /// mutation. Returning `false` from `before_publish` leaves the database
14670    /// untouched.
14671    #[doc(hidden)]
14672    pub fn doctor_controlled<F>(
14673        &self,
14674        control: &crate::ExecutionControl,
14675        before_publish: F,
14676    ) -> Result<Vec<u64>>
14677    where
14678        F: FnOnce() -> bool,
14679    {
14680        self.doctor_controlled_with_receipt(control, before_publish)
14681            .map(|(quarantined, _)| quarantined)
14682    }
14683
14684    /// Check cancellably and return the exact catalog epoch used for a
14685    /// quarantine publication. No receipt is returned when nothing changes.
14686    #[doc(hidden)]
14687    pub fn doctor_controlled_with_receipt<F>(
14688        &self,
14689        control: &crate::ExecutionControl,
14690        before_publish: F,
14691    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
14692    where
14693        F: FnOnce() -> bool,
14694    {
14695        // Hold the DDL lock for the whole operation to prevent concurrent
14696        // create_table/drop_table from racing the catalog/dir mutation.
14697        let _ddl = self.ddl_lock.lock();
14698        let _security_write = self.security_write()?;
14699        let issues = self.check_inner(Some(control))?;
14700        // A corrupt WAL segment is reported as an error but is NOT a table
14701        // problem — quarantining an innocent table cannot fix it (and the first
14702        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
14703        // them disjoint). The admin must address WAL corruption manually.
14704        let bad_tables: std::collections::HashSet<u64> = issues
14705            .iter()
14706            .filter(|i| {
14707                i.severity == "error"
14708                    && i.table_id != WAL_TABLE_ID
14709                    && i.table_id != EXTERNAL_TABLE_ID
14710            })
14711            .map(|i| i.table_id)
14712            .collect();
14713        if bad_tables.is_empty() {
14714            return Ok((Vec::new(), None));
14715        }
14716        let _commit = self.commit_lock.lock();
14717        control.checkpoint()?;
14718        if !before_publish() {
14719            return Err(MongrelError::Cancelled);
14720        }
14721        let maintenance_epoch = self.epoch.bump_assigned();
14722        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
14723
14724        let qdir = self.root.join("_quarantine");
14725        crate::durable_file::create_directory(&qdir)?;
14726        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
14727        bad_tables.sort_unstable();
14728
14729        // Quiesce every mounted target before catalog publication. Existing
14730        // handle clones are marked unavailable in the publication callback so
14731        // they cannot append to the shared WAL after their catalog entry drops.
14732        let mut handles = self
14733            .tables
14734            .read()
14735            .iter()
14736            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
14737            .map(|(table_id, handle)| (*table_id, handle.clone()))
14738            .collect::<Vec<_>>();
14739        handles.sort_by_key(|(table_id, _)| *table_id);
14740        let mut table_guards = handles
14741            .iter()
14742            .map(|(table_id, handle)| (*table_id, handle.lock()))
14743            .collect::<Vec<_>>();
14744
14745        let mut next_catalog = self.catalog.read().clone();
14746        for table_id in &bad_tables {
14747            if let Some(entry) = next_catalog
14748                .tables
14749                .iter_mut()
14750                .find(|entry| entry.table_id == *table_id)
14751            {
14752                entry.state = TableState::Dropped {
14753                    at_epoch: maintenance_epoch.0,
14754                };
14755            }
14756        }
14757        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
14758
14759        let txn_id = self.alloc_txn_id()?;
14760        let commit_seq = {
14761            let mut wal = self.shared_wal.lock();
14762            let append: Result<u64> = (|| {
14763                for table_id in &bad_tables {
14764                    wal.append(
14765                        txn_id,
14766                        *table_id,
14767                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
14768                            table_id: *table_id,
14769                        }),
14770                    )?;
14771                }
14772                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14773                wal.append_commit(txn_id, maintenance_epoch, &[])
14774            })();
14775            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
14776        };
14777        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
14778        for (_, table) in &mut table_guards {
14779            table.mark_unavailable_after_quarantine();
14780        }
14781        {
14782            let mut live_tables = self.tables.write();
14783            for table_id in &bad_tables {
14784                live_tables.remove(table_id);
14785            }
14786        }
14787        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14788        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
14789
14790        // The catalog drop is durable. Directory placement is secondary but
14791        // still uses a write-through rename. A failure reports the known
14792        // catalog outcome and leaves a harmless orphan under `tables/`.
14793        for table_id in &bad_tables {
14794            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
14795            if source.exists() {
14796                let destination = qdir.join(table_id.to_string());
14797                if let Err(error) = crate::durable_file::rename(&source, &destination) {
14798                    return Err(MongrelError::DurableCommit {
14799                        epoch: maintenance_epoch.0,
14800                        message: format!(
14801                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
14802                        ),
14803                    });
14804                }
14805            }
14806        }
14807        Ok((
14808            bad_tables,
14809            Some(MaintenanceReceipt {
14810                epoch: maintenance_epoch,
14811            }),
14812        ))
14813    }
14814
14815    /// The DB-wide KEK (if encrypted).
14816    #[allow(dead_code)]
14817    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
14818        self.kek.as_ref()
14819    }
14820
14821    /// Shared epoch authority (used by the transaction layer in P2).
14822    #[allow(dead_code)]
14823    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
14824        &self.epoch
14825    }
14826
14827    /// Shared snapshot registry (used by GC in P3.6).
14828    #[allow(dead_code)]
14829    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
14830        &self.snapshots
14831    }
14832}
14833
14834fn external_state_dir(root: &Path, name: &str) -> PathBuf {
14835    root.join(VTAB_DIR).join(name)
14836}
14837
14838fn append_catalog_snapshot(
14839    wal: &mut crate::wal::SharedWal,
14840    txn_id: u64,
14841    catalog: &Catalog,
14842) -> Result<()> {
14843    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
14844    wal.append(
14845        txn_id,
14846        WAL_TABLE_ID,
14847        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
14848    )?;
14849    Ok(())
14850}
14851
14852fn filter_ignored_staging(
14853    staging: Vec<(u64, crate::txn::Staged)>,
14854    ignored_indices: &std::collections::BTreeSet<usize>,
14855) -> Vec<(u64, crate::txn::Staged)> {
14856    if ignored_indices.is_empty() {
14857        return staging;
14858    }
14859    staging
14860        .into_iter()
14861        .enumerate()
14862        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
14863        .collect()
14864}
14865
14866fn external_state_file(root: &Path, name: &str) -> PathBuf {
14867    external_state_dir(root, name).join("state.json")
14868}
14869
14870fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
14871    let path = external_state_file(root, name);
14872    match std::fs::read(path) {
14873        Ok(bytes) => Ok(bytes),
14874        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
14875        Err(e) => Err(e.into()),
14876    }
14877}
14878
14879fn current_external_state_bytes(
14880    root: &Path,
14881    external_states: &[(String, Vec<u8>)],
14882    name: &str,
14883) -> Result<Vec<u8>> {
14884    for (table, state) in external_states.iter().rev() {
14885        if table == name {
14886            return Ok(state.clone());
14887        }
14888    }
14889    read_external_state_file(root, name)
14890}
14891
14892fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
14893    let mut out = external_states;
14894    dedup_external_states_in_place(&mut out);
14895    out
14896}
14897
14898fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
14899    let mut seen = std::collections::HashSet::new();
14900    let mut out = Vec::with_capacity(external_states.len());
14901    for (name, state) in std::mem::take(external_states).into_iter().rev() {
14902        if seen.insert(name.clone()) {
14903            out.push((name, state));
14904        }
14905    }
14906    out.reverse();
14907    *external_states = out;
14908}
14909
14910fn prepare_external_state_file(
14911    root: &Path,
14912    name: &str,
14913    state: &[u8],
14914    txn_id: u64,
14915) -> Result<PathBuf> {
14916    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
14917    let dir = external_state_dir(root, name);
14918    crate::durable_file::create_directory(&dir)?;
14919    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
14920    {
14921        let mut file = std::fs::OpenOptions::new()
14922            .create_new(true)
14923            .write(true)
14924            .open(&pending)?;
14925        file.write_all(state)?;
14926        file.sync_all()?;
14927    }
14928    Ok(pending)
14929}
14930
14931fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
14932    let path = external_state_file(root, name);
14933    crate::durable_file::replace(pending, &path)?;
14934    Ok(())
14935}
14936
14937fn write_external_state_file(
14938    durable: &crate::durable_file::DurableRoot,
14939    name: &str,
14940    state: &[u8],
14941) -> Result<()> {
14942    let directory = Path::new(VTAB_DIR).join(name);
14943    durable.create_directory_all(&directory)?;
14944    durable.write_atomic(directory.join("state.json"), state)?;
14945    Ok(())
14946}
14947
14948fn validate_recovered_data_table(
14949    catalog: &Catalog,
14950    tables: &HashMap<u64, TableHandle>,
14951    table_id: u64,
14952    commit_epoch: u64,
14953    offset: u64,
14954) -> Result<bool> {
14955    let entry = catalog
14956        .tables
14957        .iter()
14958        .find(|entry| entry.table_id == table_id)
14959        .ok_or_else(|| MongrelError::CorruptWal {
14960            offset,
14961            reason: format!("committed record references unknown table {table_id}"),
14962        })?;
14963    if commit_epoch < entry.created_epoch {
14964        return Err(MongrelError::CorruptWal {
14965            offset,
14966            reason: format!(
14967                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
14968                entry.created_epoch
14969            ),
14970        });
14971    }
14972    match entry.state {
14973        TableState::Dropped { at_epoch } => {
14974            // Abandoned hidden builds are marked dropped at the last durable
14975            // boundary during open, so their final build commit may equal the
14976            // cleanup epoch. Ordinary table drops consume a new epoch and must
14977            // remain strictly later than every data commit.
14978            let abandoned_build_boundary =
14979                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
14980            if commit_epoch >= at_epoch && !abandoned_build_boundary {
14981                Err(MongrelError::CorruptWal {
14982                    offset,
14983                    reason: format!(
14984                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
14985                    ),
14986                })
14987            } else {
14988                Ok(false)
14989            }
14990        }
14991        TableState::Live | TableState::Building { .. } => {
14992            if tables.contains_key(&table_id) {
14993                Ok(true)
14994            } else {
14995                Err(MongrelError::CorruptWal {
14996                    offset,
14997                    reason: format!("live table {table_id} has no mounted recovery handle"),
14998                })
14999            }
15000        }
15001    }
15002}
15003
15004type RecoveryTableStage = (
15005    Vec<crate::memtable::Row>,
15006    Vec<(crate::rowid::RowId, Epoch)>,
15007    Option<Epoch>,
15008    Epoch,
15009);
15010
15011#[derive(Clone)]
15012struct RecoveryValidationTable {
15013    schema: Schema,
15014    flushed_epoch: u64,
15015}
15016
15017fn validate_shared_wal_recovery_plan(
15018    durable_root: &crate::durable_file::DurableRoot,
15019    catalog: &Catalog,
15020    recovered_table_ids: &HashSet<u64>,
15021    reconciled_table_ids: &HashSet<u64>,
15022    meta_dek: Option<&[u8; META_DEK_LEN]>,
15023    kek: Option<Arc<crate::encryption::Kek>>,
15024    records: &[crate::wal::Record],
15025) -> Result<()> {
15026    use crate::wal::{DdlOp, Op};
15027
15028    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
15029    for entry in &catalog.tables {
15030        if !matches!(entry.state, TableState::Live) {
15031            continue;
15032        }
15033        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15034        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
15035            Ok(manifest) => Some(manifest),
15036            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
15037            Err(error) => return Err(error),
15038        };
15039        let flushed_epoch = if let Some(manifest) = manifest {
15040            if manifest.table_id != entry.table_id {
15041                return Err(MongrelError::Conflict(format!(
15042                    "catalog table {} storage identity mismatch",
15043                    entry.table_id
15044                )));
15045            }
15046            if (manifest.schema_id != entry.schema.schema_id
15047                && !reconciled_table_ids.contains(&entry.table_id))
15048                || manifest.flushed_epoch > manifest.current_epoch
15049                || manifest.global_idx_epoch > manifest.current_epoch
15050                || manifest.next_row_id == u64::MAX
15051                || manifest.auto_inc_next < 0
15052                || manifest.auto_inc_next == i64::MAX
15053                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
15054            {
15055                return Err(MongrelError::InvalidArgument(format!(
15056                    "table {} manifest counters or schema identity are invalid",
15057                    entry.table_id
15058                )));
15059            }
15060            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
15061            crate::global_idx::read_durable_for(
15062                durable_root,
15063                &relative_dir,
15064                entry.table_id,
15065                &entry.schema,
15066                idx_dek.as_deref(),
15067            )?;
15068            let mut run_ids = HashSet::new();
15069            let mut maximum_row_id = None::<u64>;
15070            for run in &manifest.runs {
15071                if run.run_id >= u64::MAX as u128
15072                    || run.epoch_created > manifest.current_epoch
15073                    || !run_ids.insert(run.run_id)
15074                {
15075                    return Err(MongrelError::InvalidArgument(format!(
15076                        "table {} manifest contains an invalid or duplicate run id",
15077                        entry.table_id
15078                    )));
15079                }
15080                let relative = relative_dir
15081                    .join(crate::engine::RUNS_DIR)
15082                    .join(format!("r-{}.sr", run.run_id as u64));
15083                let file = durable_root.open_regular(&relative)?;
15084                let mut reader = crate::sorted_run::RunReader::open_file(
15085                    file,
15086                    entry.schema.clone(),
15087                    kek.clone(),
15088                )?;
15089                let header = reader.header();
15090                if header.run_id != run.run_id
15091                    || header.level != run.level
15092                    || header.row_count != run.row_count
15093                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
15094                    || header.is_uniform_epoch() && header.epoch_created != 0
15095                    || header.schema_id > entry.schema.schema_id
15096                {
15097                    return Err(MongrelError::InvalidArgument(format!(
15098                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
15099                        entry.table_id,
15100                        run.run_id,
15101                        header.run_id,
15102                        header.level,
15103                        header.row_count,
15104                        header.epoch_created,
15105                        header.schema_id,
15106                        run.run_id,
15107                        run.level,
15108                        run.row_count,
15109                        run.epoch_created,
15110                        entry.schema.schema_id,
15111                    )));
15112                }
15113                if header.row_count != 0 {
15114                    maximum_row_id = Some(
15115                        maximum_row_id
15116                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
15117                    );
15118                }
15119                reader.validate_all_pages()?;
15120            }
15121            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
15122                return Err(MongrelError::InvalidArgument(format!(
15123                    "table {} next_row_id does not advance beyond persisted rows",
15124                    entry.table_id
15125                )));
15126            }
15127            for run in &manifest.retiring {
15128                if run.run_id >= u64::MAX as u128
15129                    || run.retire_epoch > manifest.current_epoch
15130                    || !run_ids.insert(run.run_id)
15131                {
15132                    return Err(MongrelError::InvalidArgument(format!(
15133                        "table {} manifest contains an invalid or aliased retired run",
15134                        entry.table_id
15135                    )));
15136                }
15137            }
15138            manifest.flushed_epoch
15139        } else {
15140            if !recovered_table_ids.contains(&entry.table_id) {
15141                return Err(MongrelError::NotFound(format!(
15142                    "live table {} manifest is missing",
15143                    entry.table_id
15144                )));
15145            }
15146            0
15147        };
15148        tables.insert(
15149            entry.table_id,
15150            RecoveryValidationTable {
15151                schema: entry.schema.clone(),
15152                flushed_epoch,
15153            },
15154        );
15155    }
15156
15157    let committed = records
15158        .iter()
15159        .filter_map(|record| match record.op {
15160            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
15161            _ => None,
15162        })
15163        .collect::<HashMap<_, _>>();
15164    let mut run_ids = HashSet::new();
15165    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
15166    for record in records {
15167        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
15168            continue;
15169        };
15170        match &record.op {
15171            Op::Put { table_id, rows } => {
15172                let table = validate_recovery_data_table_plan(
15173                    catalog,
15174                    &tables,
15175                    *table_id,
15176                    commit_epoch,
15177                    record.seq.0,
15178                )?;
15179                let decoded: Vec<crate::memtable::Row> =
15180                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
15181                        offset: record.seq.0,
15182                        reason: format!(
15183                            "committed Put payload for transaction {} could not be decoded: {error}",
15184                            record.txn_id
15185                        ),
15186                    })?;
15187                if let Some(table) = table {
15188                    for row in &decoded {
15189                        if !recovered_row_ids
15190                            .entry(*table_id)
15191                            .or_default()
15192                            .insert(row.row_id.0)
15193                        {
15194                            return Err(MongrelError::CorruptWal {
15195                                offset: record.seq.0,
15196                                reason: format!(
15197                                    "committed WAL repeats recovered row id {} for table {table_id}",
15198                                    row.row_id.0
15199                                ),
15200                            });
15201                        }
15202                        validate_recovered_row(&table.schema, row)?;
15203                    }
15204                }
15205            }
15206            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
15207                validate_recovery_data_table_plan(
15208                    catalog,
15209                    &tables,
15210                    *table_id,
15211                    commit_epoch,
15212                    record.seq.0,
15213                )?;
15214            }
15215            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
15216            Op::Ddl(DdlOp::ResetExternalTableState {
15217                name,
15218                generation_epoch,
15219            }) => {
15220                if *generation_epoch != commit_epoch {
15221                    return Err(MongrelError::CorruptWal {
15222                        offset: record.seq.0,
15223                        reason: format!(
15224                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
15225                        ),
15226                    });
15227                }
15228                validate_recovered_external_name(name)?;
15229            }
15230            Op::TxnCommit { added_runs, .. } => {
15231                for added in added_runs {
15232                    let Some(table) = validate_recovery_data_table_plan(
15233                        catalog,
15234                        &tables,
15235                        added.table_id,
15236                        commit_epoch,
15237                        record.seq.0,
15238                    )?
15239                    else {
15240                        continue;
15241                    };
15242                    if added.run_id >= u64::MAX as u128
15243                        || !run_ids.insert((added.table_id, added.run_id))
15244                    {
15245                        return Err(MongrelError::CorruptWal {
15246                            offset: record.seq.0,
15247                            reason: format!(
15248                                "duplicate or invalid recovered run {} for table {}",
15249                                added.run_id, added.table_id
15250                            ),
15251                        });
15252                    }
15253                    if commit_epoch <= table.flushed_epoch {
15254                        continue;
15255                    }
15256                    validate_planned_spilled_run(
15257                        durable_root,
15258                        record.txn_id,
15259                        commit_epoch,
15260                        added,
15261                        &table.schema,
15262                        kek.clone(),
15263                    )?;
15264                }
15265            }
15266            _ => {}
15267        }
15268    }
15269    Ok(())
15270}
15271
15272fn validate_recovery_data_table_plan<'a>(
15273    catalog: &Catalog,
15274    tables: &'a HashMap<u64, RecoveryValidationTable>,
15275    table_id: u64,
15276    commit_epoch: u64,
15277    offset: u64,
15278) -> Result<Option<&'a RecoveryValidationTable>> {
15279    let entry = catalog
15280        .tables
15281        .iter()
15282        .find(|entry| entry.table_id == table_id)
15283        .ok_or_else(|| MongrelError::CorruptWal {
15284            offset,
15285            reason: format!("committed record references unknown table {table_id}"),
15286        })?;
15287    if commit_epoch < entry.created_epoch {
15288        return Err(MongrelError::CorruptWal {
15289            offset,
15290            reason: format!(
15291                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
15292                entry.created_epoch
15293            ),
15294        });
15295    }
15296    match entry.state {
15297        TableState::Dropped { at_epoch } => {
15298            let abandoned =
15299                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
15300            if commit_epoch >= at_epoch && !abandoned {
15301                return Err(MongrelError::CorruptWal {
15302                    offset,
15303                    reason: format!(
15304                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
15305                    ),
15306                });
15307            }
15308            Ok(None)
15309        }
15310        TableState::Live => {
15311            tables
15312                .get(&table_id)
15313                .map(Some)
15314                .ok_or_else(|| MongrelError::CorruptWal {
15315                    offset,
15316                    reason: format!("live table {table_id} has no recovery plan"),
15317                })
15318        }
15319        TableState::Building { .. } => Err(MongrelError::CorruptWal {
15320            offset,
15321            reason: format!("building table {table_id} was not normalized before recovery"),
15322        }),
15323    }
15324}
15325
15326fn validate_planned_spilled_run(
15327    root: &crate::durable_file::DurableRoot,
15328    txn_id: u64,
15329    commit_epoch: u64,
15330    added: &crate::wal::AddedRun,
15331    schema: &Schema,
15332    kek: Option<Arc<crate::encryption::Kek>>,
15333) -> Result<()> {
15334    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
15335    let destination = table
15336        .join(crate::engine::RUNS_DIR)
15337        .join(format!("r-{}.sr", added.run_id as u64));
15338    let pending = table
15339        .join("_txn")
15340        .join(txn_id.to_string())
15341        .join(format!("r-{}.sr", added.run_id as u64));
15342    let file = match root.open_regular(&destination) {
15343        Ok(file) => file,
15344        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
15345            root.open_regular(&pending).map_err(|pending_error| {
15346                if pending_error.kind() == std::io::ErrorKind::NotFound {
15347                    MongrelError::CorruptWal {
15348                        offset: commit_epoch,
15349                        reason: format!(
15350                            "committed spilled run {} for transaction {txn_id} is missing",
15351                            added.run_id
15352                        ),
15353                    }
15354                } else {
15355                    pending_error.into()
15356                }
15357            })?
15358        }
15359        Err(error) => return Err(error.into()),
15360    };
15361    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
15362    let header = reader.header();
15363    if header.run_id != added.run_id
15364        || header.content_hash != added.content_hash
15365        || header.row_count != added.row_count
15366        || header.level != added.level
15367        || header.min_row_id != added.min_row_id
15368        || header.max_row_id != added.max_row_id
15369        || header.schema_id != schema.schema_id
15370        || !header.is_uniform_epoch()
15371        || header.epoch_created != 0
15372    {
15373        return Err(MongrelError::CorruptWal {
15374            offset: commit_epoch,
15375            reason: format!(
15376                "committed spilled run {} metadata differs from WAL",
15377                added.run_id
15378            ),
15379        });
15380    }
15381    reader.validate_all_pages()?;
15382    Ok(())
15383}
15384
15385/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
15386///
15387/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
15388/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
15389/// 2 applies each committed data record (Put/Delete) to its table at the commit
15390/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
15391/// durable in a sorted run). Finally the shared epoch authority is raised to the
15392/// max committed epoch so the next commit continues monotonically.
15393/// The staged-write payload contract of a distributed-transaction write
15394/// intent (spec section 12.8). A participant in two-phase commit stages its
15395/// writes as opaque intent payloads (`WriteIntent::value_ref` in
15396/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
15397/// this engine-defined encoding is the whole contract. At prepare time the
15398/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
15399/// committed resolution they are applied through
15400/// [`Database::apply_staged_txn_writes`].
15401///
15402/// The encoding is bincode over this enum (the same codec the WAL frame
15403/// payloads use); discriminants are never reused.
15404#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15405pub enum StagedTxnWrite {
15406    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
15407    /// identical payload shape an `Op::Put` WAL record carries). Row commit
15408    /// epochs are placeholders — the resolution apply restamps every row at
15409    /// the synthetic commit epoch.
15410    Put {
15411        /// The mounted table the rows target.
15412        table_id: u64,
15413        /// Bincode `Vec<crate::memtable::Row>`.
15414        rows: Vec<u8>,
15415    },
15416    /// Staged row deletes by row id.
15417    Delete {
15418        /// The mounted table the deletes target.
15419        table_id: u64,
15420        /// Row ids (`crate::RowId` values) to delete.
15421        row_ids: Vec<u64>,
15422    },
15423}
15424
15425impl StagedTxnWrite {
15426    /// Serializes deterministically (bincode over the enum).
15427    pub fn encode(&self) -> Result<Vec<u8>> {
15428        Ok(bincode::serialize(self)?)
15429    }
15430
15431    /// Decodes one staged-write payload, failing closed on malformed input.
15432    pub fn decode(bytes: &[u8]) -> Result<Self> {
15433        Ok(bincode::deserialize(bytes)?)
15434    }
15435}
15436
15437/// Leader-side spill translation for the replicated write path (spec section
15438/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
15439///
15440/// A transaction whose staged puts exceed the spill threshold commits with
15441/// its rows in a leader-local sorted run: the commit marker's `added_runs`
15442/// links the run file and the rows also ride the WAL as logical
15443/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
15444/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
15445/// and because the raft entry is already quorum-committed, an apply-time
15446/// rejection wedges the whole group's apply stream. The leader therefore
15447/// translates the staged record sequence **before proposal**:
15448///
15449/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
15450///   (identical row bytes; recovery restamps the rows at the commit epoch),
15451/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
15452///   reach a replica),
15453/// - every other record passes through byte-identical.
15454///
15455/// The standalone commit path is untouched: the leader's own WAL keeps the
15456/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
15457/// links the run; this function reads but never mutates its input.
15458///
15459/// Translation is total for any sequence the commit sequencer actually
15460/// produced. As a fail-closed guard against malformed or truncated captures,
15461/// the sequence is structurally validated (one transaction, exactly one
15462/// trailing commit marker), every spill payload must decode, and every linked
15463/// run's rows must be provably present as logical records (the row-id range
15464/// covers `row_count` rows); a violation rejects the proposal with
15465/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
15466/// post-commit. (Taxonomy: `InvalidArgument` maps to
15467/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
15468/// disagreement that is never retried unchanged. The normative spec category
15469/// for "the commit was not applied; only a fresh transaction may succeed" is
15470/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
15471/// `error.rs`, which is outside this change's file scope — tracked as a
15472/// follow-up.)
15473pub fn translate_records_for_replication(
15474    records: &[crate::wal::Record],
15475) -> Result<Vec<crate::wal::Record>> {
15476    use crate::wal::Op;
15477
15478    // Structural validation mirrors `apply_replicated_records`: one
15479    // transaction, exactly one commit marker, at the tail.
15480    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
15481        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
15482    })?;
15483    if records.iter().any(|record| record.txn_id != txn_id) {
15484        return Err(MongrelError::InvalidArgument(
15485            "replicated transaction payload mixes transaction ids".into(),
15486        ));
15487    }
15488    let commits = records
15489        .iter()
15490        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
15491        .count();
15492    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
15493        return Err(MongrelError::InvalidArgument(
15494            "replicated transaction payload must end in exactly one commit marker".into(),
15495        ));
15496    }
15497
15498    // Decode every logical spill payload now: a payload that cannot decode
15499    // here would fail every replica's apply identically — reject at propose
15500    // time instead.
15501    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
15502    for record in records {
15503        if let Op::SpilledRows { table_id, rows } = &record.op {
15504            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
15505                MongrelError::InvalidArgument(format!(
15506                    "spilled row payload for table {table_id} cannot decode for replication: \
15507                         {error}"
15508                ))
15509            })?;
15510            spilled_rows.entry(*table_id).or_default().extend(chunk);
15511        }
15512    }
15513
15514    // Coverage proof: every run the commit marker links must have its full
15515    // row content present as logical records, or replicas would silently
15516    // lose those rows.
15517    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
15518        unreachable!("one trailing commit marker validated above");
15519    };
15520    for run in added_runs {
15521        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
15522            MongrelError::InvalidArgument(format!(
15523                "commit links spilled run {} for table {} but carries no logical row records \
15524                 for it",
15525                run.run_id, run.table_id
15526            ))
15527        })?;
15528        let covered = rows
15529            .iter()
15530            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
15531            .count() as u64;
15532        if covered != run.row_count {
15533            return Err(MongrelError::InvalidArgument(format!(
15534                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
15535                 row records cover {} rows",
15536                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
15537            )));
15538        }
15539    }
15540
15541    // Translate: spill payloads become ordinary puts; the commit marker no
15542    // longer references leader-local run files.
15543    let translated = records
15544        .iter()
15545        .map(|record| {
15546            let op = match &record.op {
15547                Op::SpilledRows { table_id, rows } => Op::Put {
15548                    table_id: *table_id,
15549                    rows: rows.clone(),
15550                },
15551                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
15552                    epoch: *epoch,
15553                    added_runs: Vec::new(),
15554                },
15555                op => op.clone(),
15556            };
15557            crate::wal::Record::new(record.seq, record.txn_id, op)
15558        })
15559        .collect();
15560    Ok(translated)
15561}
15562
15563fn recover_shared_wal(
15564    durable_root: &crate::durable_file::DurableRoot,
15565    tables: &HashMap<u64, TableHandle>,
15566    catalog: &Catalog,
15567    epoch: &EpochAuthority,
15568    records: &[crate::wal::Record],
15569) -> Result<()> {
15570    use crate::memtable::Row;
15571    use crate::wal::{DdlOp, Op};
15572
15573    // Pass 1: committed-txn outcomes + collect spilled-run info.
15574    let mut committed: HashMap<u64, u64> = HashMap::new();
15575    let mut spilled_to_link: Vec<(
15576        u64, /*txn_id*/
15577        u64, /*epoch*/
15578        Vec<crate::wal::AddedRun>,
15579    )> = Vec::new();
15580    for r in records {
15581        if let Op::TxnCommit {
15582            epoch: ce,
15583            ref added_runs,
15584        } = r.op
15585        {
15586            committed.insert(r.txn_id, ce);
15587            if !added_runs.is_empty() {
15588                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
15589            }
15590        }
15591    }
15592    for record in records {
15593        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
15594            continue;
15595        };
15596        match &record.op {
15597            Op::Put { table_id, .. }
15598            | Op::Delete { table_id, .. }
15599            | Op::TruncateTable { table_id } => {
15600                validate_recovered_data_table(
15601                    catalog,
15602                    tables,
15603                    *table_id,
15604                    commit_epoch,
15605                    record.seq.0,
15606                )?;
15607            }
15608            Op::TxnCommit { added_runs, .. } => {
15609                for run in added_runs {
15610                    validate_recovered_data_table(
15611                        catalog,
15612                        tables,
15613                        run.table_id,
15614                        commit_epoch,
15615                        record.seq.0,
15616                    )?;
15617                }
15618            }
15619            _ => {}
15620        }
15621    }
15622    let truncated_transactions: HashSet<(u64, u64)> = records
15623        .iter()
15624        .filter_map(|record| {
15625            committed.get(&record.txn_id)?;
15626            match record.op {
15627                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
15628                _ => None,
15629            }
15630        })
15631        .collect();
15632
15633    // Pass 2: stage data per table, gated by flushed_epoch.
15634    enum ExternalRecoveryAction {
15635        Write { name: String, state: Vec<u8> },
15636        Reset { name: String },
15637    }
15638    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
15639    let mut external_actions = Vec::new();
15640    let mut max_epoch = epoch.visible().0;
15641    for r in records.iter().cloned() {
15642        let Some(&ce) = committed.get(&r.txn_id) else {
15643            continue; // aborted / in-flight — discard
15644        };
15645        let commit_epoch = Epoch(ce);
15646        max_epoch = max_epoch.max(ce);
15647        match r.op {
15648            Op::Put { table_id, rows } => {
15649                // Skip if this table already flushed past the commit epoch.
15650                let skip = tables
15651                    .get(&table_id)
15652                    .map(|h| h.lock().flushed_epoch() >= ce)
15653                    .unwrap_or(true);
15654                if skip {
15655                    continue;
15656                }
15657                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
15658                    MongrelError::CorruptWal {
15659                        offset: r.seq.0,
15660                        reason: format!(
15661                            "committed Put payload for transaction {} could not be decoded: {error}",
15662                            r.txn_id
15663                        ),
15664                    }
15665                })?;
15666                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
15667                // at pending_epoch which equals the commit epoch, but be robust).
15668                let rows: Vec<Row> = rows
15669                    .into_iter()
15670                    .map(|mut row| {
15671                        row.committed_epoch = commit_epoch;
15672                        row
15673                    })
15674                    .collect();
15675                let entry = stage
15676                    .entry(table_id)
15677                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
15678                entry.0.extend(rows);
15679                entry.3 = commit_epoch;
15680            }
15681            Op::Delete { table_id, row_ids } => {
15682                let skip = tables
15683                    .get(&table_id)
15684                    .map(|h| h.lock().flushed_epoch() >= ce)
15685                    .unwrap_or(true);
15686                if skip {
15687                    continue;
15688                }
15689                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
15690                let entry = stage
15691                    .entry(table_id)
15692                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
15693                entry.1.extend(dels);
15694                entry.3 = commit_epoch;
15695            }
15696            Op::TruncateTable { table_id } => {
15697                let skip = tables
15698                    .get(&table_id)
15699                    .map(|h| h.lock().flushed_epoch() >= ce)
15700                    .unwrap_or(true);
15701                if skip {
15702                    continue;
15703                }
15704                stage.insert(
15705                    table_id,
15706                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
15707                );
15708            }
15709            Op::ExternalTableState { name, state } => {
15710                let current_generation = catalog
15711                    .external_tables
15712                    .iter()
15713                    .find(|entry| entry.name == name)
15714                    .map(|entry| entry.created_epoch);
15715                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
15716                    validate_recovered_external_name(&name)?;
15717                    external_actions.push(ExternalRecoveryAction::Write { name, state });
15718                }
15719            }
15720            Op::Ddl(DdlOp::ResetExternalTableState {
15721                name,
15722                generation_epoch,
15723            }) => {
15724                if generation_epoch != ce {
15725                    return Err(MongrelError::CorruptWal {
15726                        offset: r.seq.0,
15727                        reason: format!(
15728                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
15729                    ),
15730                    });
15731                }
15732                validate_recovered_external_name(&name)?;
15733                external_actions.push(ExternalRecoveryAction::Reset { name });
15734            }
15735            Op::Flush { .. }
15736            | Op::TxnCommit { .. }
15737            | Op::TxnAbort
15738            | Op::Ddl(_)
15739            | Op::BeforeImage { .. }
15740            | Op::CommitTimestamp { .. }
15741            | Op::SpilledRows { .. } => {}
15742        }
15743    }
15744    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
15745        added_runs.retain(|added| {
15746            tables
15747                .get(&added.table_id)
15748                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
15749        });
15750    }
15751    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
15752    validate_recovery_table_stages(tables, &stage)?;
15753    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
15754
15755    // All WAL payloads, catalog generations, table stages, and immutable run
15756    // identities have now been validated. Only this application phase mutates
15757    // the database tree.
15758    for action in external_actions {
15759        match action {
15760            ExternalRecoveryAction::Write { name, state } => {
15761                write_external_state_file(durable_root, &name, &state)?;
15762            }
15763            ExternalRecoveryAction::Reset { name } => {
15764                durable_root.create_directory_all(VTAB_DIR)?;
15765                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
15766            }
15767        }
15768    }
15769    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
15770        let Some(handle) = tables.get(&table_id) else {
15771            continue;
15772        };
15773        let mut t = handle.lock();
15774        if let Some(epoch) = truncate_epoch {
15775            t.apply_truncate(epoch);
15776        }
15777        t.recover_apply(rows, deletes)?;
15778        // The WAL can be newer than the copied/persisted manifest after a
15779        // crash or replication apply. Rebuild O(1) count metadata from the
15780        // recovered state before endorsing the commit epoch in the manifest.
15781        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
15782        t.live_count = rows.len() as u64;
15783        // Recovery can replay older row commits while a newer spilled run is
15784        // already linked by the copied manifest. Never move that manifest's
15785        // epoch behind its existing run references.
15786        t.persist_manifest(table_epoch.max(epoch.visible()))?;
15787    }
15788
15789    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
15790    // between TxnCommit sync and the publish phase leaves the run in
15791    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
15792    for (txn_id, ce, added_runs) in &spilled_to_link {
15793        for ar in added_runs {
15794            let Some(handle) = tables.get(&ar.table_id) else {
15795                continue;
15796            };
15797            let mut t = handle.lock();
15798            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
15799            let destination = table_dir
15800                .join(crate::engine::RUNS_DIR)
15801                .join(format!("r-{}.sr", ar.run_id));
15802            match durable_root.open_regular(&destination) {
15803                Ok(_) => {}
15804                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
15805                    let pending = table_dir
15806                        .join("_txn")
15807                        .join(txn_id.to_string())
15808                        .join(format!("r-{}.sr", ar.run_id));
15809                    durable_root.rename_file_new(&pending, &destination)?;
15810                }
15811                Err(error) => return Err(error.into()),
15812            }
15813            // Only link a run whose file is actually present, and never re-link
15814            // one the publish phase already persisted into the manifest (which is
15815            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
15816            // until segment GC). `recover_spilled_run` is idempotent + reconciles
15817            // `live_count`/indexes only when the run is genuinely new.
15818            let linked = t.recover_spilled_run(crate::manifest::RunRef {
15819                run_id: ar.run_id,
15820                level: ar.level,
15821                epoch_created: *ce,
15822                row_count: ar.row_count,
15823            });
15824            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
15825            if replaced {
15826                t.set_flushed_epoch(Epoch(*ce));
15827            }
15828            if linked || replaced {
15829                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
15830            }
15831        }
15832    }
15833
15834    epoch.advance_recovered(Epoch(max_epoch));
15835    Ok(())
15836}
15837
15838fn reconcile_recovered_table_metadata(
15839    tables: &HashMap<u64, TableHandle>,
15840    epoch: Epoch,
15841) -> Result<()> {
15842    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
15843    table_ids.sort_unstable();
15844    let mut plans = Vec::with_capacity(table_ids.len());
15845    for table_id in &table_ids {
15846        let handle = tables.get(table_id).ok_or_else(|| {
15847            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
15848        })?;
15849        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
15850    }
15851    // Every table's data and metadata have been decoded successfully. Publish
15852    // repairs only after the complete database-wide plan is known valid.
15853    for (table_id, plan) in plans {
15854        let handle = tables.get(&table_id).ok_or_else(|| {
15855            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
15856        })?;
15857        handle.lock().apply_recovered_metadata(plan, epoch)?;
15858    }
15859    Ok(())
15860}
15861
15862fn validate_recovered_external_name(name: &str) -> Result<()> {
15863    if name.is_empty()
15864        || !name.chars().all(|character| {
15865            character.is_ascii_alphanumeric() || character == '_' || character == '-'
15866        })
15867    {
15868        return Err(MongrelError::CorruptWal {
15869            offset: 0,
15870            reason: format!("unsafe recovered external-table name {name:?}"),
15871        });
15872    }
15873    Ok(())
15874}
15875
15876fn validate_recovery_table_stages(
15877    tables: &HashMap<u64, TableHandle>,
15878    stages: &HashMap<u64, RecoveryTableStage>,
15879) -> Result<()> {
15880    for (table_id, (rows, _, _, _)) in stages {
15881        let handle = tables
15882            .get(table_id)
15883            .ok_or_else(|| MongrelError::CorruptWal {
15884                offset: *table_id,
15885                reason: format!("recovery stage references unmounted table {table_id}"),
15886            })?;
15887        let table = handle.lock();
15888        // Force all existing immutable runs through their integrity/decode path
15889        // before any other table manifest can be changed.
15890        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
15891        for row in rows {
15892            validate_recovered_row(table.schema(), row)?;
15893        }
15894    }
15895    Ok(())
15896}
15897
15898fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
15899    if row.deleted || row.row_id.0 == u64::MAX {
15900        return Err(MongrelError::CorruptWal {
15901            offset: row.row_id.0,
15902            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
15903        });
15904    }
15905    let cells = row
15906        .columns
15907        .iter()
15908        .map(|(column, value)| (*column, value.clone()))
15909        .collect::<Vec<_>>();
15910    schema
15911        .validate_persisted_values(&cells)
15912        .map_err(|error| MongrelError::CorruptWal {
15913            offset: row.row_id.0,
15914            reason: format!("recovered row violates table schema: {error}"),
15915        })?;
15916    if schema.auto_increment_column().is_some_and(|column| {
15917        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
15918    }) {
15919        return Err(MongrelError::CorruptWal {
15920            offset: row.row_id.0,
15921            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
15922        });
15923    }
15924    Ok(())
15925}
15926
15927fn validate_recovery_spilled_runs(
15928    root: &crate::durable_file::DurableRoot,
15929    tables: &HashMap<u64, TableHandle>,
15930    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
15931) -> Result<()> {
15932    let mut identities = HashSet::new();
15933    for (txn_id, commit_epoch, added_runs) in spilled {
15934        for added in added_runs {
15935            if added.run_id >= u64::MAX as u128 {
15936                return Err(MongrelError::CorruptWal {
15937                    offset: *commit_epoch,
15938                    reason: format!(
15939                        "recovered run id {} exceeds the on-disk namespace",
15940                        added.run_id
15941                    ),
15942                });
15943            }
15944            let Some(handle) = tables.get(&added.table_id) else {
15945                continue;
15946            };
15947            if !identities.insert((added.table_id, added.run_id)) {
15948                return Err(MongrelError::CorruptWal {
15949                    offset: *commit_epoch,
15950                    reason: format!(
15951                        "duplicate recovered run {} for table {}",
15952                        added.run_id, added.table_id
15953                    ),
15954                });
15955            }
15956            let table = handle.lock();
15957            validate_planned_spilled_run(
15958                root,
15959                *txn_id,
15960                *commit_epoch,
15961                added,
15962                table.schema(),
15963                table.kek(),
15964            )?;
15965        }
15966    }
15967    Ok(())
15968}
15969
15970fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
15971    match condition {
15972        ProcedureCondition::Pk { .. } => {
15973            if schema.primary_key().is_none() {
15974                return Err(MongrelError::InvalidArgument(
15975                    "procedure condition Pk references a table without a primary key".into(),
15976                ));
15977            }
15978        }
15979        ProcedureCondition::BitmapEq { column_id, .. }
15980        | ProcedureCondition::BitmapIn { column_id, .. }
15981        | ProcedureCondition::Range { column_id, .. }
15982        | ProcedureCondition::RangeF64 { column_id, .. }
15983        | ProcedureCondition::IsNull { column_id }
15984        | ProcedureCondition::IsNotNull { column_id }
15985        | ProcedureCondition::FmContains { column_id, .. } => {
15986            validate_column_id(*column_id, schema)?;
15987        }
15988    }
15989    Ok(())
15990}
15991
15992fn bind_procedure_args(
15993    procedure: &StoredProcedure,
15994    mut args: HashMap<String, crate::Value>,
15995) -> Result<HashMap<String, crate::Value>> {
15996    let mut out = HashMap::new();
15997    for param in &procedure.params {
15998        let value = match args.remove(&param.name) {
15999            Some(value) => value,
16000            None => param.default.clone().ok_or_else(|| {
16001                MongrelError::InvalidArgument(format!(
16002                    "missing required procedure parameter {:?}",
16003                    param.name
16004                ))
16005            })?,
16006        };
16007        if !param.nullable && matches!(value, crate::Value::Null) {
16008            return Err(MongrelError::InvalidArgument(format!(
16009                "procedure parameter {:?} must not be NULL",
16010                param.name
16011            )));
16012        }
16013        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
16014            return Err(MongrelError::InvalidArgument(format!(
16015                "procedure parameter {:?} has wrong type",
16016                param.name
16017            )));
16018        }
16019        out.insert(param.name.clone(), value);
16020    }
16021    if let Some(extra) = args.keys().next() {
16022        return Err(MongrelError::InvalidArgument(format!(
16023            "unknown procedure parameter {extra:?}"
16024        )));
16025    }
16026    Ok(out)
16027}
16028
16029fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
16030    matches!(
16031        (value, ty),
16032        (crate::Value::Bool(_), crate::TypeId::Bool)
16033            | (crate::Value::Int64(_), crate::TypeId::Int8)
16034            | (crate::Value::Int64(_), crate::TypeId::Int16)
16035            | (crate::Value::Int64(_), crate::TypeId::Int32)
16036            | (crate::Value::Int64(_), crate::TypeId::Int64)
16037            | (crate::Value::Int64(_), crate::TypeId::UInt8)
16038            | (crate::Value::Int64(_), crate::TypeId::UInt16)
16039            | (crate::Value::Int64(_), crate::TypeId::UInt32)
16040            | (crate::Value::Int64(_), crate::TypeId::UInt64)
16041            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
16042            | (crate::Value::Int64(_), crate::TypeId::Date32)
16043            | (crate::Value::Float64(_), crate::TypeId::Float32)
16044            | (crate::Value::Float64(_), crate::TypeId::Float64)
16045            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
16046            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
16047    )
16048}
16049
16050fn eval_cells(
16051    cells: &[crate::procedure::ProcedureCell],
16052    args: &HashMap<String, crate::Value>,
16053    outputs: &HashMap<String, ProcedureCallOutput>,
16054) -> Result<Vec<(u16, crate::Value)>> {
16055    cells
16056        .iter()
16057        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
16058        .collect()
16059}
16060
16061fn eval_condition(
16062    condition: &ProcedureCondition,
16063    args: &HashMap<String, crate::Value>,
16064    outputs: &HashMap<String, ProcedureCallOutput>,
16065) -> Result<crate::Condition> {
16066    Ok(match condition {
16067        ProcedureCondition::Pk { value } => {
16068            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
16069        }
16070        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
16071            column_id: *column_id,
16072            value: eval_value(value, args, outputs)?.encode_key(),
16073        },
16074        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
16075            column_id: *column_id,
16076            values: values
16077                .iter()
16078                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
16079                .collect::<Result<Vec<_>>>()?,
16080        },
16081        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
16082            column_id: *column_id,
16083            lo: expect_i64(eval_value(lo, args, outputs)?)?,
16084            hi: expect_i64(eval_value(hi, args, outputs)?)?,
16085        },
16086        ProcedureCondition::RangeF64 {
16087            column_id,
16088            lo,
16089            lo_inclusive,
16090            hi,
16091            hi_inclusive,
16092        } => crate::Condition::RangeF64 {
16093            column_id: *column_id,
16094            lo: expect_f64(eval_value(lo, args, outputs)?)?,
16095            lo_inclusive: *lo_inclusive,
16096            hi: expect_f64(eval_value(hi, args, outputs)?)?,
16097            hi_inclusive: *hi_inclusive,
16098        },
16099        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
16100            column_id: *column_id,
16101        },
16102        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
16103            column_id: *column_id,
16104        },
16105        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
16106            column_id: *column_id,
16107            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
16108        },
16109    })
16110}
16111
16112fn eval_value(
16113    value: &ProcedureValue,
16114    args: &HashMap<String, crate::Value>,
16115    outputs: &HashMap<String, ProcedureCallOutput>,
16116) -> Result<crate::Value> {
16117    match value {
16118        ProcedureValue::Literal(value) => Ok(value.clone()),
16119        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
16120            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
16121        }),
16122        ProcedureValue::StepScalar(id) => match outputs.get(id) {
16123            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
16124            _ => Err(MongrelError::InvalidArgument(format!(
16125                "procedure step {id:?} did not return a scalar"
16126            ))),
16127        },
16128        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
16129            Err(MongrelError::InvalidArgument(
16130                "row-valued procedure reference cannot be used as a scalar".into(),
16131            ))
16132        }
16133        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
16134            "structured procedure value cannot be used as a scalar cell".into(),
16135        )),
16136    }
16137}
16138
16139fn eval_return_output(
16140    value: &ProcedureValue,
16141    args: &HashMap<String, crate::Value>,
16142    outputs: &HashMap<String, ProcedureCallOutput>,
16143) -> Result<ProcedureCallOutput> {
16144    match value {
16145        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
16146        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
16147            args.get(name).cloned().ok_or_else(|| {
16148                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
16149            })?,
16150        )),
16151        ProcedureValue::StepRows(id)
16152        | ProcedureValue::StepRow(id)
16153        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
16154            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
16155        }),
16156        ProcedureValue::Object(fields) => {
16157            let mut out = Vec::with_capacity(fields.len());
16158            for (name, value) in fields {
16159                out.push((name.clone(), eval_return_output(value, args, outputs)?));
16160            }
16161            Ok(ProcedureCallOutput::Object(out))
16162        }
16163        ProcedureValue::Array(values) => {
16164            let mut out = Vec::with_capacity(values.len());
16165            for value in values {
16166                out.push(eval_return_output(value, args, outputs)?);
16167            }
16168            Ok(ProcedureCallOutput::Array(out))
16169        }
16170    }
16171}
16172
16173fn expect_i64(value: crate::Value) -> Result<i64> {
16174    match value {
16175        crate::Value::Int64(value) => Ok(value),
16176        _ => Err(MongrelError::InvalidArgument(
16177            "procedure value must be Int64".into(),
16178        )),
16179    }
16180}
16181
16182fn expect_f64(value: crate::Value) -> Result<f64> {
16183    match value {
16184        crate::Value::Float64(value) => Ok(value),
16185        _ => Err(MongrelError::InvalidArgument(
16186            "procedure value must be Float64".into(),
16187        )),
16188    }
16189}
16190
16191fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
16192    match value {
16193        crate::Value::Bytes(value) => Ok(value),
16194        _ => Err(MongrelError::InvalidArgument(
16195            "procedure value must be Bytes".into(),
16196        )),
16197    }
16198}
16199
16200fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
16201    if schema.columns.iter().any(|c| c.id == column_id) {
16202        Ok(())
16203    } else {
16204        Err(MongrelError::InvalidArgument(format!(
16205            "unknown column id {column_id}"
16206        )))
16207    }
16208}
16209
16210fn trigger_matches_event(
16211    trigger: &StoredTrigger,
16212    event: &WriteEvent,
16213    cat: &Catalog,
16214) -> Result<bool> {
16215    if trigger.event != event.kind {
16216        return Ok(false);
16217    }
16218    let TriggerTarget::Table(target) = &trigger.target else {
16219        return Ok(false);
16220    };
16221    if target != &event.table {
16222        return Ok(false);
16223    }
16224    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
16225        let schema = &cat
16226            .live(target)
16227            .ok_or_else(|| {
16228                MongrelError::InvalidArgument(format!(
16229                    "trigger {:?} references unknown table {target:?}",
16230                    trigger.name
16231                ))
16232            })?
16233            .schema;
16234        let mut watched = Vec::with_capacity(trigger.update_of.len());
16235        for name in &trigger.update_of {
16236            let col = schema.column(name).ok_or_else(|| {
16237                MongrelError::InvalidArgument(format!(
16238                    "trigger {:?} references unknown UPDATE OF column {name:?}",
16239                    trigger.name
16240                ))
16241            })?;
16242            watched.push(col.id);
16243        }
16244        if !event
16245            .changed_columns
16246            .iter()
16247            .any(|column_id| watched.contains(column_id))
16248        {
16249            return Ok(false);
16250        }
16251    }
16252    Ok(true)
16253}
16254
16255fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
16256    let mut ids = std::collections::BTreeSet::new();
16257    if let Some(old) = old {
16258        ids.extend(old.columns.keys().copied());
16259    }
16260    if let Some(new) = new {
16261        ids.extend(new.columns.keys().copied());
16262    }
16263    ids.into_iter()
16264        .filter(|id| {
16265            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
16266        })
16267        .collect()
16268}
16269
16270fn eval_trigger_cells(
16271    cells: &[crate::trigger::TriggerCell],
16272    event: &WriteEvent,
16273    selected: Option<&TriggerRowImage>,
16274) -> Result<Vec<(u16, Value)>> {
16275    cells
16276        .iter()
16277        .map(|cell| {
16278            Ok((
16279                cell.column_id,
16280                eval_trigger_value(&cell.value, event, selected)?,
16281            ))
16282        })
16283        .collect()
16284}
16285
16286fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
16287    match expr {
16288        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
16289            Value::Bool(value) => Ok(value),
16290            Value::Null => Ok(false),
16291            other => Err(MongrelError::InvalidArgument(format!(
16292                "trigger WHEN value must be boolean, got {other:?}"
16293            ))),
16294        },
16295        TriggerExpr::Eq { left, right } => Ok(values_equal(
16296            &eval_trigger_value(left, event, None)?,
16297            &eval_trigger_value(right, event, None)?,
16298        )),
16299        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
16300            &eval_trigger_value(left, event, None)?,
16301            &eval_trigger_value(right, event, None)?,
16302        )),
16303        TriggerExpr::Lt { left, right } => match value_order(
16304            &eval_trigger_value(left, event, None)?,
16305            &eval_trigger_value(right, event, None)?,
16306        ) {
16307            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
16308            None => Ok(false),
16309        },
16310        TriggerExpr::Lte { left, right } => match value_order(
16311            &eval_trigger_value(left, event, None)?,
16312            &eval_trigger_value(right, event, None)?,
16313        ) {
16314            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
16315            None => Ok(false),
16316        },
16317        TriggerExpr::Gt { left, right } => match value_order(
16318            &eval_trigger_value(left, event, None)?,
16319            &eval_trigger_value(right, event, None)?,
16320        ) {
16321            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
16322            None => Ok(false),
16323        },
16324        TriggerExpr::Gte { left, right } => match value_order(
16325            &eval_trigger_value(left, event, None)?,
16326            &eval_trigger_value(right, event, None)?,
16327        ) {
16328            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
16329            None => Ok(false),
16330        },
16331        TriggerExpr::IsNull(value) => Ok(matches!(
16332            eval_trigger_value(value, event, None)?,
16333            Value::Null
16334        )),
16335        TriggerExpr::IsNotNull(value) => Ok(!matches!(
16336            eval_trigger_value(value, event, None)?,
16337            Value::Null
16338        )),
16339        TriggerExpr::And { left, right } => {
16340            if !eval_trigger_expr(left, event)? {
16341                Ok(false)
16342            } else {
16343                Ok(eval_trigger_expr(right, event)?)
16344            }
16345        }
16346        TriggerExpr::Or { left, right } => {
16347            if eval_trigger_expr(left, event)? {
16348                Ok(true)
16349            } else {
16350                Ok(eval_trigger_expr(right, event)?)
16351            }
16352        }
16353        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
16354    }
16355}
16356
16357fn eval_trigger_condition(
16358    condition: &TriggerCondition,
16359    event: &WriteEvent,
16360    selected: &TriggerRowImage,
16361    schema: &Schema,
16362) -> Result<bool> {
16363    match condition {
16364        TriggerCondition::Pk { value } => {
16365            let pk = schema.primary_key().ok_or_else(|| {
16366                MongrelError::InvalidArgument(
16367                    "trigger condition Pk references a table without a primary key".into(),
16368                )
16369            })?;
16370            let lhs = eval_trigger_value(value, event, Some(selected))?;
16371            Ok(values_equal(
16372                &lhs,
16373                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
16374            ))
16375        }
16376        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
16377            selected.columns.get(column_id).unwrap_or(&Value::Null),
16378            &eval_trigger_value(value, event, Some(selected))?,
16379        )),
16380        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
16381            selected.columns.get(column_id).unwrap_or(&Value::Null),
16382            &eval_trigger_value(value, event, Some(selected))?,
16383        )),
16384        TriggerCondition::Lt { column_id, value } => match value_order(
16385            selected.columns.get(column_id).unwrap_or(&Value::Null),
16386            &eval_trigger_value(value, event, Some(selected))?,
16387        ) {
16388            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
16389            None => Ok(false),
16390        },
16391        TriggerCondition::Lte { column_id, value } => match value_order(
16392            selected.columns.get(column_id).unwrap_or(&Value::Null),
16393            &eval_trigger_value(value, event, Some(selected))?,
16394        ) {
16395            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
16396            None => Ok(false),
16397        },
16398        TriggerCondition::Gt { column_id, value } => match value_order(
16399            selected.columns.get(column_id).unwrap_or(&Value::Null),
16400            &eval_trigger_value(value, event, Some(selected))?,
16401        ) {
16402            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
16403            None => Ok(false),
16404        },
16405        TriggerCondition::Gte { column_id, value } => match value_order(
16406            selected.columns.get(column_id).unwrap_or(&Value::Null),
16407            &eval_trigger_value(value, event, Some(selected))?,
16408        ) {
16409            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
16410            None => Ok(false),
16411        },
16412        TriggerCondition::IsNull { column_id } => Ok(matches!(
16413            selected.columns.get(column_id),
16414            None | Some(Value::Null)
16415        )),
16416        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
16417            selected.columns.get(column_id),
16418            None | Some(Value::Null)
16419        )),
16420        TriggerCondition::And { left, right } => {
16421            if !eval_trigger_condition(left, event, selected, schema)? {
16422                Ok(false)
16423            } else {
16424                Ok(eval_trigger_condition(right, event, selected, schema)?)
16425            }
16426        }
16427        TriggerCondition::Or { left, right } => {
16428            if eval_trigger_condition(left, event, selected, schema)? {
16429                Ok(true)
16430            } else {
16431                Ok(eval_trigger_condition(right, event, selected, schema)?)
16432            }
16433        }
16434        TriggerCondition::Not(condition) => {
16435            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
16436        }
16437    }
16438}
16439
16440fn eval_trigger_value(
16441    value: &TriggerValue,
16442    event: &WriteEvent,
16443    selected: Option<&TriggerRowImage>,
16444) -> Result<Value> {
16445    match value {
16446        TriggerValue::Literal(value) => Ok(value.clone()),
16447        TriggerValue::NewColumn(column_id) => event
16448            .new
16449            .as_ref()
16450            .and_then(|row| row.columns.get(column_id))
16451            .cloned()
16452            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
16453        TriggerValue::OldColumn(column_id) => event
16454            .old
16455            .as_ref()
16456            .and_then(|row| row.columns.get(column_id))
16457            .cloned()
16458            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
16459        TriggerValue::SelectedColumn(column_id) => selected
16460            .and_then(|row| row.columns.get(column_id))
16461            .cloned()
16462            .ok_or_else(|| {
16463                MongrelError::InvalidArgument("SELECTED column is not available".into())
16464            }),
16465    }
16466}
16467
16468fn values_equal(left: &Value, right: &Value) -> bool {
16469    match (left, right) {
16470        (Value::Null, Value::Null) => true,
16471        (Value::Bool(a), Value::Bool(b)) => a == b,
16472        (Value::Int64(a), Value::Int64(b)) => a == b,
16473        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
16474        (Value::Bytes(a), Value::Bytes(b)) => a == b,
16475        (Value::Embedding(a), Value::Embedding(b)) => {
16476            a.len() == b.len()
16477                && a.iter()
16478                    .zip(b.iter())
16479                    .all(|(a, b)| a.to_bits() == b.to_bits())
16480        }
16481        _ => false,
16482    }
16483}
16484
16485fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
16486    match (left, right) {
16487        (Value::Null, _) | (_, Value::Null) => None,
16488        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
16489        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
16490        // Cross-type Int64/Float64 comparison coerces the integer to f64.
16491        // This matches the spec but can lose precision for i64 values above 2^53.
16492        (Value::Int64(a), Value::Float64(b)) => {
16493            let af = *a as f64;
16494            Some(af.total_cmp(b))
16495        }
16496        // Cross-type Int64/Float64 comparison coerces the integer to f64.
16497        // This matches the spec but can lose precision for i64 values above 2^53.
16498        (Value::Float64(a), Value::Int64(b)) => {
16499            let bf = *b as f64;
16500            Some(a.total_cmp(&bf))
16501        }
16502        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
16503        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
16504        (Value::Embedding(_), Value::Embedding(_)) => None,
16505        _ => None,
16506    }
16507}
16508
16509fn trigger_message(value: Value) -> String {
16510    match value {
16511        Value::Null => "NULL".into(),
16512        Value::Bool(value) => value.to_string(),
16513        Value::Int64(value) => value.to_string(),
16514        Value::Float64(value) => value.to_string(),
16515        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
16516        Value::Embedding(value) => format!("{value:?}"),
16517        Value::Decimal(value) => value.to_string(),
16518        Value::Interval {
16519            months,
16520            days,
16521            nanos,
16522        } => format!("{months}m {days}d {nanos}ns"),
16523        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
16524        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
16525    }
16526}
16527
16528fn validate_trigger_step<'a>(
16529    step: &TriggerStep,
16530    cat: &'a Catalog,
16531    target_schema: &Schema,
16532    event: TriggerEvent,
16533    select_schemas: &mut HashMap<String, &'a Schema>,
16534) -> Result<()> {
16535    match step {
16536        TriggerStep::SetNew { cells } => {
16537            if event == TriggerEvent::Delete {
16538                return Err(MongrelError::InvalidArgument(
16539                    "SetNew trigger step is not valid for DELETE triggers".into(),
16540                ));
16541            }
16542            for cell in cells {
16543                validate_column_id(cell.column_id, target_schema)?;
16544                validate_trigger_value(&cell.value, target_schema, event)?;
16545            }
16546        }
16547        TriggerStep::Insert { table, cells } => {
16548            let schema = trigger_write_schema(cat, table, "insert")?;
16549            for cell in cells {
16550                validate_column_id(cell.column_id, schema)?;
16551                validate_trigger_value(&cell.value, target_schema, event)?;
16552            }
16553        }
16554        TriggerStep::UpdateByPk { table, pk, cells } => {
16555            let schema = trigger_write_schema(cat, table, "update")?;
16556            if schema.primary_key().is_none() {
16557                return Err(MongrelError::InvalidArgument(format!(
16558                    "trigger update_by_pk references table {table:?} without a primary key"
16559                )));
16560            }
16561            validate_trigger_value(pk, target_schema, event)?;
16562            for cell in cells {
16563                validate_column_id(cell.column_id, schema)?;
16564                validate_trigger_value(&cell.value, target_schema, event)?;
16565            }
16566        }
16567        TriggerStep::DeleteByPk { table, pk } => {
16568            let schema = trigger_write_schema(cat, table, "delete")?;
16569            if schema.primary_key().is_none() {
16570                return Err(MongrelError::InvalidArgument(format!(
16571                    "trigger delete_by_pk references table {table:?} without a primary key"
16572                )));
16573            }
16574            validate_trigger_value(pk, target_schema, event)?;
16575        }
16576        TriggerStep::Select {
16577            id,
16578            table,
16579            conditions,
16580        } => {
16581            let schema = trigger_read_schema(cat, table)?;
16582            for condition in conditions {
16583                validate_trigger_condition(condition, schema, target_schema, event)?;
16584            }
16585            if select_schemas.contains_key(id) {
16586                return Err(MongrelError::InvalidArgument(format!(
16587                    "duplicate select id {id:?} in trigger program"
16588                )));
16589            }
16590            select_schemas.insert(id.clone(), schema);
16591        }
16592        TriggerStep::Foreach { id, steps } => {
16593            if !select_schemas.contains_key(id) {
16594                return Err(MongrelError::InvalidArgument(format!(
16595                    "foreach references unknown select id {id:?}"
16596                )));
16597            }
16598            let mut inner_select_schemas = select_schemas.clone();
16599            for step in steps {
16600                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
16601            }
16602        }
16603        TriggerStep::DeleteWhere { table, conditions } => {
16604            let schema = trigger_write_schema(cat, table, "delete")?;
16605            for condition in conditions {
16606                validate_trigger_condition(condition, schema, target_schema, event)?;
16607            }
16608        }
16609        TriggerStep::UpdateWhere {
16610            table,
16611            conditions,
16612            cells,
16613        } => {
16614            let schema = trigger_write_schema(cat, table, "update")?;
16615            for condition in conditions {
16616                validate_trigger_condition(condition, schema, target_schema, event)?;
16617            }
16618            for cell in cells {
16619                validate_column_id(cell.column_id, schema)?;
16620                validate_trigger_value(&cell.value, target_schema, event)?;
16621            }
16622        }
16623        TriggerStep::Raise { message, .. } => {
16624            validate_trigger_value(message, target_schema, event)?
16625        }
16626    }
16627    Ok(())
16628}
16629
16630fn trigger_validation_error(error: MongrelError) -> MongrelError {
16631    match error {
16632        MongrelError::TriggerValidation(_) => error,
16633        MongrelError::InvalidArgument(message)
16634        | MongrelError::Conflict(message)
16635        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
16636        error => error,
16637    }
16638}
16639
16640fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
16641    if let Some(entry) = cat.live(table) {
16642        return Ok(&entry.schema);
16643    }
16644    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
16645        let allowed = match op {
16646            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
16647            "update" | "delete" => entry.capabilities.writable,
16648            _ => false,
16649        };
16650        if !allowed {
16651            return Err(MongrelError::InvalidArgument(format!(
16652                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
16653                entry.module
16654            )));
16655        }
16656        if !entry.capabilities.transaction_safe {
16657            return Err(MongrelError::InvalidArgument(format!(
16658                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
16659                entry.module
16660            )));
16661        }
16662        return Ok(&entry.declared_schema);
16663    }
16664    Err(MongrelError::InvalidArgument(format!(
16665        "trigger references unknown table {table:?}"
16666    )))
16667}
16668
16669fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
16670    if let Some(entry) = cat.live(table) {
16671        return Ok(&entry.schema);
16672    }
16673    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
16674        if entry.capabilities.trigger_safe {
16675            return Ok(&entry.declared_schema);
16676        }
16677        return Err(MongrelError::InvalidArgument(format!(
16678            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
16679            entry.module
16680        )));
16681    }
16682    Err(MongrelError::InvalidArgument(format!(
16683        "trigger references unknown table {table:?}"
16684    )))
16685}
16686
16687fn validate_trigger_condition(
16688    condition: &TriggerCondition,
16689    schema: &Schema,
16690    target_schema: &Schema,
16691    event: TriggerEvent,
16692) -> Result<()> {
16693    match condition {
16694        TriggerCondition::Pk { value } => {
16695            if schema.primary_key().is_none() {
16696                return Err(MongrelError::InvalidArgument(
16697                    "trigger condition Pk references a table without a primary key".into(),
16698                ));
16699            }
16700            validate_trigger_value(value, target_schema, event)
16701        }
16702        TriggerCondition::Eq { column_id, value }
16703        | TriggerCondition::NotEq { column_id, value }
16704        | TriggerCondition::Lt { column_id, value }
16705        | TriggerCondition::Lte { column_id, value }
16706        | TriggerCondition::Gt { column_id, value }
16707        | TriggerCondition::Gte { column_id, value } => {
16708            validate_column_id(*column_id, schema)?;
16709            validate_trigger_value(value, target_schema, event)
16710        }
16711        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
16712            validate_column_id(*column_id, schema)
16713        }
16714        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
16715            validate_trigger_condition(left, schema, target_schema, event)?;
16716            validate_trigger_condition(right, schema, target_schema, event)
16717        }
16718        TriggerCondition::Not(condition) => {
16719            validate_trigger_condition(condition, schema, target_schema, event)
16720        }
16721    }
16722}
16723
16724fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
16725    match expr {
16726        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
16727            validate_trigger_value(value, schema, event)
16728        }
16729        TriggerExpr::Eq { left, right }
16730        | TriggerExpr::NotEq { left, right }
16731        | TriggerExpr::Lt { left, right }
16732        | TriggerExpr::Lte { left, right }
16733        | TriggerExpr::Gt { left, right }
16734        | TriggerExpr::Gte { left, right } => {
16735            validate_trigger_value(left, schema, event)?;
16736            validate_trigger_value(right, schema, event)
16737        }
16738        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
16739            validate_trigger_expr(left, schema, event)?;
16740            validate_trigger_expr(right, schema, event)
16741        }
16742        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
16743    }
16744}
16745
16746fn validate_trigger_value(
16747    value: &TriggerValue,
16748    schema: &Schema,
16749    event: TriggerEvent,
16750) -> Result<()> {
16751    match value {
16752        TriggerValue::Literal(_) => Ok(()),
16753        TriggerValue::NewColumn(id) => {
16754            if event == TriggerEvent::Delete {
16755                return Err(MongrelError::InvalidArgument(
16756                    "DELETE triggers cannot reference NEW".into(),
16757                ));
16758            }
16759            validate_column_id(*id, schema)
16760        }
16761        TriggerValue::OldColumn(id) => {
16762            if event == TriggerEvent::Insert {
16763                return Err(MongrelError::InvalidArgument(
16764                    "INSERT triggers cannot reference OLD".into(),
16765                ));
16766            }
16767            validate_column_id(*id, schema)
16768        }
16769        // SELECTED column references are only meaningful inside a foreach loop.
16770        // Strict loop-scope validation is deferred to runtime; the executor raises
16771        // an error if a selected row is not available.
16772        TriggerValue::SelectedColumn(_) => Ok(()),
16773    }
16774}
16775
16776/// Bound on the retained tail of the per-open commit-timestamp ledger
16777/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
16778/// needs recent epochs, so the map keeps the newest commits and drops the
16779/// rest (a miss falls back to a fresh-begin HLC at the caller).
16780const COMMIT_TS_LEDGER_CAP: usize = 10_000;
16781
16782/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
16783/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
16784/// and the `Op::CommitTimestamp` record written ahead of it carries the
16785/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
16786/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
16787/// micros only (same constraint [`crate::commit_log`] documents for replayed
16788/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
16789fn commit_ts_ledger_from_recovery(
16790    records: &[crate::wal::Record],
16791) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
16792    use crate::wal::Op;
16793    let mut commits = HashMap::new();
16794    let mut timestamps = HashMap::new();
16795    for record in records {
16796        match &record.op {
16797            Op::TxnCommit { epoch, .. } => {
16798                commits.insert(record.txn_id, *epoch);
16799            }
16800            Op::CommitTimestamp { unix_nanos } => {
16801                timestamps.insert(record.txn_id, *unix_nanos);
16802            }
16803            _ => {}
16804        }
16805    }
16806    let mut ledger = std::collections::BTreeMap::new();
16807    for (txn_id, epoch) in commits {
16808        let Some(unix_nanos) = timestamps.get(&txn_id) else {
16809            continue;
16810        };
16811        ledger.insert(
16812            epoch,
16813            mongreldb_types::hlc::HlcTimestamp {
16814                physical_micros: unix_nanos / 1_000,
16815                logical: 0,
16816                node_tiebreaker: 0,
16817            },
16818        );
16819    }
16820    while ledger.len() > COMMIT_TS_LEDGER_CAP {
16821        ledger.pop_first();
16822    }
16823    ledger
16824}
16825
16826/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
16827/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
16828/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
16829/// catalog. This pass closes that window by reconstructing missing entries
16830/// (and marking committed drops) before tables are mounted.
16831fn recover_ddl_from_wal(
16832    root: &Path,
16833    durable_root: Option<&crate::durable_file::DurableRoot>,
16834    target_catalog: &mut Catalog,
16835    meta_dek: Option<&[u8; META_DEK_LEN]>,
16836    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
16837    apply: bool,
16838    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
16839) -> Result<()> {
16840    use crate::wal::SharedWal;
16841    let records = match durable_root {
16842        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
16843        None => SharedWal::replay_with_dek(root, wal_dek)?,
16844    };
16845    recover_ddl_from_records(
16846        root,
16847        durable_root,
16848        target_catalog,
16849        meta_dek,
16850        apply,
16851        table_roots,
16852        &records,
16853    )
16854}
16855
16856fn recover_ddl_from_records(
16857    root: &Path,
16858    durable_root: Option<&crate::durable_file::DurableRoot>,
16859    target_catalog: &mut Catalog,
16860    meta_dek: Option<&[u8; META_DEK_LEN]>,
16861    apply: bool,
16862    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
16863    records: &[crate::wal::Record],
16864) -> Result<()> {
16865    use crate::wal::{DdlOp, Op};
16866
16867    let original_catalog = target_catalog.clone();
16868    let mut recovered_catalog = original_catalog.clone();
16869    let cat = &mut recovered_catalog;
16870    let mut created_table_ids = HashSet::<u64>::new();
16871    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
16872
16873    let mut committed: HashMap<u64, u64> = HashMap::new();
16874    for r in records {
16875        if let Op::TxnCommit { epoch: ce, .. } = r.op {
16876            committed.insert(r.txn_id, ce);
16877        }
16878    }
16879    let catalog_snapshot_txns = records
16880        .iter()
16881        .filter_map(|record| {
16882            (committed.contains_key(&record.txn_id)
16883                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
16884            .then_some(record.txn_id)
16885        })
16886        .collect::<HashSet<_>>();
16887
16888    let mut changed = false;
16889    let mut applied_catalog_epoch = cat.db_epoch;
16890    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
16891    for r in records.iter().cloned() {
16892        let Some(&ce) = committed.get(&r.txn_id) else {
16893            continue;
16894        };
16895        let txn_id = r.txn_id;
16896        match r.op {
16897            Op::Ddl(DdlOp::CreateTable {
16898                table_id,
16899                ref name,
16900                ref schema_json,
16901            }) => {
16902                if cat.tables.iter().any(|t| t.table_id == table_id) {
16903                    continue;
16904                }
16905                let schema = DdlOp::decode_schema(schema_json)?;
16906                validate_recovered_schema(&schema)?;
16907                created_table_ids.insert(table_id);
16908                cat.tables.push(CatalogEntry {
16909                    table_id,
16910                    name: name.clone(),
16911                    schema,
16912                    state: TableState::Live,
16913                    created_epoch: ce,
16914                });
16915                cat.next_table_id =
16916                    cat.next_table_id
16917                        .max(table_id.checked_add(1).ok_or_else(|| {
16918                            MongrelError::Full("table id namespace exhausted".into())
16919                        })?);
16920                changed = true;
16921            }
16922            Op::Ddl(DdlOp::CreateBuildingTable {
16923                table_id,
16924                ref build_name,
16925                ref intended_name,
16926                ref query_id,
16927                created_at_unix_nanos,
16928                ref schema_json,
16929            }) => {
16930                if cat.tables.iter().any(|table| table.table_id == table_id) {
16931                    continue;
16932                }
16933                let schema = DdlOp::decode_schema(schema_json)?;
16934                validate_recovered_schema(&schema)?;
16935                created_table_ids.insert(table_id);
16936                cat.tables.push(CatalogEntry {
16937                    table_id,
16938                    name: build_name.clone(),
16939                    schema,
16940                    state: TableState::Building {
16941                        intended_name: intended_name.clone(),
16942                        query_id: query_id.clone(),
16943                        created_at_unix_nanos,
16944                        replaces_table_id: None,
16945                    },
16946                    created_epoch: ce,
16947                });
16948                cat.next_table_id =
16949                    cat.next_table_id
16950                        .max(table_id.checked_add(1).ok_or_else(|| {
16951                            MongrelError::Full("table id namespace exhausted".into())
16952                        })?);
16953                changed = true;
16954            }
16955            Op::Ddl(DdlOp::CreateRebuildingTable {
16956                table_id,
16957                ref build_name,
16958                ref intended_name,
16959                ref query_id,
16960                created_at_unix_nanos,
16961                replaces_table_id,
16962                ref schema_json,
16963            }) => {
16964                if cat.tables.iter().any(|table| table.table_id == table_id) {
16965                    continue;
16966                }
16967                let schema = DdlOp::decode_schema(schema_json)?;
16968                validate_recovered_schema(&schema)?;
16969                created_table_ids.insert(table_id);
16970                cat.tables.push(CatalogEntry {
16971                    table_id,
16972                    name: build_name.clone(),
16973                    schema,
16974                    state: TableState::Building {
16975                        intended_name: intended_name.clone(),
16976                        query_id: query_id.clone(),
16977                        created_at_unix_nanos,
16978                        replaces_table_id: Some(replaces_table_id),
16979                    },
16980                    created_epoch: ce,
16981                });
16982                cat.next_table_id =
16983                    cat.next_table_id
16984                        .max(table_id.checked_add(1).ok_or_else(|| {
16985                            MongrelError::Full("table id namespace exhausted".into())
16986                        })?);
16987                changed = true;
16988            }
16989            Op::Ddl(DdlOp::DropTable { table_id }) => {
16990                let mut dropped_name = None;
16991                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
16992                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
16993                        dropped_name = Some(entry.name.clone());
16994                        entry.state = TableState::Dropped { at_epoch: ce };
16995                        changed = true;
16996                    }
16997                }
16998                if let Some(name) = dropped_name {
16999                    let before = cat.materialized_views.len();
17000                    cat.materialized_views
17001                        .retain(|definition| definition.name != name);
17002                    changed |= before != cat.materialized_views.len();
17003                    cat.security.rls_tables.retain(|table| table != &name);
17004                    cat.security.policies.retain(|policy| policy.table != name);
17005                    cat.security.masks.retain(|mask| mask.table != name);
17006                    for role in &mut cat.roles {
17007                        role.permissions
17008                            .retain(|permission| permission_table(permission) != Some(&name));
17009                    }
17010                    if !catalog_snapshot_txns.contains(&txn_id) {
17011                        advance_security_version(cat)?;
17012                    }
17013                }
17014            }
17015            Op::Ddl(DdlOp::PublishBuildingTable {
17016                table_id,
17017                ref new_name,
17018            }) => {
17019                if let Some(entry) = cat
17020                    .tables
17021                    .iter_mut()
17022                    .find(|table| table.table_id == table_id)
17023                {
17024                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
17025                        entry.name = new_name.clone();
17026                        entry.state = TableState::Live;
17027                        changed = true;
17028                    }
17029                }
17030            }
17031            Op::Ddl(DdlOp::ReplaceBuildingTable {
17032                table_id,
17033                replaced_table_id,
17034                ref new_name,
17035            }) => {
17036                changed |=
17037                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
17038            }
17039            Op::Ddl(DdlOp::RenameTable {
17040                table_id,
17041                ref new_name,
17042            }) => {
17043                let mut old_name = None;
17044                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
17045                    if entry.name != *new_name {
17046                        old_name = Some(entry.name.clone());
17047                        entry.name = new_name.clone();
17048                        changed = true;
17049                    }
17050                }
17051                if let Some(old_name) = old_name {
17052                    if let Some(definition) = cat
17053                        .materialized_views
17054                        .iter_mut()
17055                        .find(|definition| definition.name == old_name)
17056                    {
17057                        definition.name = new_name.clone();
17058                    }
17059                    for table in &mut cat.security.rls_tables {
17060                        if *table == old_name {
17061                            *table = new_name.clone();
17062                        }
17063                    }
17064                    for policy in &mut cat.security.policies {
17065                        if policy.table == old_name {
17066                            policy.table = new_name.clone();
17067                        }
17068                    }
17069                    for mask in &mut cat.security.masks {
17070                        if mask.table == old_name {
17071                            mask.table = new_name.clone();
17072                        }
17073                    }
17074                    for role in &mut cat.roles {
17075                        for permission in &mut role.permissions {
17076                            rename_permission_table(permission, &old_name, new_name);
17077                        }
17078                    }
17079                    if !catalog_snapshot_txns.contains(&txn_id) {
17080                        advance_security_version(cat)?;
17081                    }
17082                }
17083                // If the entry is absent, its CreateTable was already
17084                // checkpointed carrying the post-rename name, so there is
17085                // nothing to apply — a no-op, not an error.
17086            }
17087            Op::Ddl(DdlOp::AlterTable {
17088                table_id,
17089                ref column_json,
17090            }) => {
17091                let column = DdlOp::decode_column(column_json)?;
17092                let mut renamed = None;
17093                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
17094                    renamed = entry
17095                        .schema
17096                        .columns
17097                        .iter()
17098                        .find(|existing| existing.id == column.id && existing.name != column.name)
17099                        .map(|existing| {
17100                            (
17101                                entry.name.clone(),
17102                                existing.name.clone(),
17103                                column.name.clone(),
17104                            )
17105                        });
17106                    if apply_recovered_column_def(&mut entry.schema, column)? {
17107                        validate_recovered_schema(&entry.schema)?;
17108                        changed = true;
17109                    }
17110                }
17111                if let Some((table, old_name, new_name)) = renamed {
17112                    for role in &mut cat.roles {
17113                        for permission in &mut role.permissions {
17114                            rename_permission_column(permission, &table, &old_name, &new_name);
17115                        }
17116                    }
17117                    if !catalog_snapshot_txns.contains(&txn_id) {
17118                        advance_security_version(cat)?;
17119                    }
17120                }
17121            }
17122            Op::Ddl(DdlOp::SetTtl {
17123                table_id,
17124                ref policy_json,
17125            }) => {
17126                let policy = DdlOp::decode_ttl(policy_json)?;
17127                let entry = cat
17128                    .tables
17129                    .iter()
17130                    .find(|entry| entry.table_id == table_id)
17131                    .ok_or_else(|| {
17132                        MongrelError::Schema(format!(
17133                            "recovered TTL references unknown table id {table_id}"
17134                        ))
17135                    })?;
17136                if let Some(policy) = policy {
17137                    let valid = entry
17138                        .schema
17139                        .columns
17140                        .iter()
17141                        .find(|column| column.id == policy.column_id)
17142                        .is_some_and(|column| {
17143                            column.ty == TypeId::TimestampNanos
17144                                && policy.duration_nanos > 0
17145                                && policy.duration_nanos <= i64::MAX as u64
17146                        });
17147                    if !valid {
17148                        return Err(MongrelError::Schema(format!(
17149                            "invalid recovered TTL policy for table id {table_id}"
17150                        )));
17151                    }
17152                }
17153                ttl_updates.insert(table_id, (policy, ce));
17154            }
17155            Op::Ddl(DdlOp::SetMaterializedView {
17156                ref name,
17157                ref definition_json,
17158            }) => {
17159                let definition = DdlOp::decode_materialized_view(definition_json)?;
17160                if definition.name != *name {
17161                    return Err(MongrelError::Schema(format!(
17162                        "materialized view WAL name mismatch: {name:?}"
17163                    )));
17164                }
17165                if cat.live(name).is_some() {
17166                    if let Some(existing) = cat
17167                        .materialized_views
17168                        .iter_mut()
17169                        .find(|existing| existing.name == *name)
17170                    {
17171                        if *existing != definition {
17172                            *existing = definition;
17173                            changed = true;
17174                        }
17175                    } else {
17176                        cat.materialized_views.push(definition);
17177                        changed = true;
17178                    }
17179                }
17180            }
17181            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
17182                let security = DdlOp::decode_security(security_json)?;
17183                validate_security_catalog(cat, &security)?;
17184                if cat.security != security {
17185                    cat.security = security;
17186                    if !catalog_snapshot_txns.contains(&txn_id) {
17187                        advance_security_version(cat)?;
17188                    }
17189                    changed = true;
17190                }
17191            }
17192            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
17193                let target = match key.as_str() {
17194                    "user_version" => &mut cat.user_version,
17195                    "application_id" => &mut cat.application_id,
17196                    _ => {
17197                        return Err(MongrelError::InvalidArgument(format!(
17198                            "unsupported recovered SQL pragma {key:?}"
17199                        )))
17200                    }
17201                };
17202                if *target != Some(value) {
17203                    *target = Some(value);
17204                    cat.db_epoch = cat.db_epoch.max(ce);
17205                    changed = true;
17206                }
17207            }
17208            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
17209                if ce <= applied_catalog_epoch {
17210                    continue;
17211                }
17212                let snapshot = DdlOp::decode_catalog(catalog_json)?;
17213                if snapshot.db_epoch != ce {
17214                    return Err(MongrelError::Schema(format!(
17215                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
17216                        snapshot.db_epoch
17217                    )));
17218                }
17219                validate_recovered_catalog(&snapshot)?;
17220                validate_catalog_transition(cat, &snapshot)?;
17221                *cat = snapshot;
17222                applied_catalog_epoch = ce;
17223                changed = true;
17224            }
17225            _ => {}
17226        }
17227    }
17228
17229    if cat.db_epoch < max_committed_epoch {
17230        cat.db_epoch = max_committed_epoch;
17231        changed = true;
17232    }
17233    changed |= repair_catalog_allocator_counters(cat)?;
17234
17235    validate_recovered_catalog(cat)?;
17236    let storage_reconciliation = validate_recovered_storage_plan(
17237        root,
17238        durable_root,
17239        cat,
17240        &created_table_ids,
17241        &ttl_updates,
17242        meta_dek,
17243    )?;
17244
17245    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
17246    if apply && (changed || needs_storage_apply) {
17247        for table_id in storage_reconciliation {
17248            let entry = cat
17249                .tables
17250                .iter()
17251                .find(|entry| entry.table_id == table_id)
17252                .ok_or_else(|| MongrelError::CorruptWal {
17253                    offset: table_id,
17254                    reason: "recovery storage plan lost its catalog table".into(),
17255                })?;
17256            ensure_recovered_table_storage(
17257                table_roots
17258                    .and_then(|roots| roots.get(&table_id))
17259                    .map(Arc::as_ref),
17260                durable_root,
17261                &root.join(TABLES_DIR).join(table_id.to_string()),
17262                table_id,
17263                &entry.schema,
17264                meta_dek,
17265            )?;
17266        }
17267        for (table_id, (policy, ttl_epoch)) in ttl_updates {
17268            let Some(entry) = cat.tables.iter().find(|entry| {
17269                entry.table_id == table_id
17270                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
17271            }) else {
17272                continue;
17273            };
17274            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
17275            {
17276                root.try_clone()?
17277            } else if let Some(root) = durable_root {
17278                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
17279            } else {
17280                crate::durable_file::DurableRoot::open(
17281                    root.join(TABLES_DIR).join(table_id.to_string()),
17282                )?
17283            };
17284            let table_dir = table_root.io_path()?;
17285            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
17286            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
17287                manifest.ttl = policy;
17288                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
17289                manifest.schema_id = entry.schema.schema_id;
17290                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
17291            }
17292        }
17293        if changed {
17294            match durable_root {
17295                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
17296                None => catalog::write_atomic(root, cat, meta_dek)?,
17297            }
17298        }
17299    }
17300    *target_catalog = recovered_catalog;
17301    Ok(())
17302}
17303
17304fn ensure_recovered_table_storage(
17305    pinned_table: Option<&crate::durable_file::DurableRoot>,
17306    durable_root: Option<&crate::durable_file::DurableRoot>,
17307    fallback_table_dir: &Path,
17308    table_id: u64,
17309    schema: &Schema,
17310    meta_dek: Option<&[u8; META_DEK_LEN]>,
17311) -> Result<()> {
17312    let table_root = if let Some(root) = pinned_table {
17313        root.try_clone()?
17314    } else if let Some(root) = durable_root {
17315        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
17316        match root.open_directory(&relative) {
17317            Ok(table) => table,
17318            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17319                root.create_directory_all_pinned(relative)?
17320            }
17321            Err(error) => return Err(error.into()),
17322        }
17323    } else {
17324        crate::durable_file::create_directory_all(fallback_table_dir)?;
17325        crate::durable_file::DurableRoot::open(fallback_table_dir)?
17326    };
17327    let table_dir = table_root.io_path()?;
17328    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
17329        Ok(manifest) => {
17330            if manifest.table_id != table_id {
17331                return Err(MongrelError::Conflict(format!(
17332                    "recovered table directory id mismatch: expected {table_id}, found {}",
17333                    manifest.table_id
17334                )));
17335            }
17336            Some(manifest)
17337        }
17338        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
17339        Err(error) => return Err(error),
17340    };
17341
17342    table_root.create_directory_all(crate::engine::WAL_DIR)?;
17343    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
17344    crate::engine::write_schema(&table_dir, schema)?;
17345
17346    if let Some(mut manifest) = existing_manifest.take() {
17347        if manifest.schema_id != schema.schema_id {
17348            manifest.schema_id = schema.schema_id;
17349            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
17350        }
17351    } else {
17352        // The DB-wide meta DEK is also the per-table manifest meta DEK.
17353        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
17354        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
17355    }
17356    Ok(())
17357}
17358
17359fn validate_recovered_schema(schema: &Schema) -> Result<()> {
17360    schema.validate_auto_increment()?;
17361    schema.validate_defaults()?;
17362    schema.validate_ai()?;
17363    let mut column_ids = HashSet::new();
17364    let mut column_names = HashSet::new();
17365    for column in &schema.columns {
17366        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
17367            return Err(MongrelError::Schema(
17368                "recovered schema contains duplicate columns".into(),
17369            ));
17370        }
17371        match &column.ty {
17372            TypeId::Decimal128 { precision, scale }
17373                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
17374            {
17375                return Err(MongrelError::Schema(format!(
17376                    "column {:?} has invalid decimal precision or scale",
17377                    column.name
17378                )));
17379            }
17380            TypeId::Enum { variants }
17381                if variants.is_empty()
17382                    || variants.iter().any(String::is_empty)
17383                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
17384            {
17385                return Err(MongrelError::Schema(format!(
17386                    "column {:?} has invalid enum variants",
17387                    column.name
17388                )));
17389            }
17390            _ => {}
17391        }
17392    }
17393    let mut index_names = HashSet::new();
17394    for index in &schema.indexes {
17395        index.validate_options()?;
17396        if index.name.is_empty()
17397            || !index_names.insert(index.name.as_str())
17398            || schema
17399                .columns
17400                .iter()
17401                .all(|column| column.id != index.column_id)
17402        {
17403            return Err(MongrelError::Schema(format!(
17404                "recovered index {:?} references missing column {}",
17405                index.name, index.column_id
17406            )));
17407        }
17408    }
17409    let mut colocated = HashSet::new();
17410    for group in &schema.colocation {
17411        if group.is_empty()
17412            || group.iter().any(|id| !column_ids.contains(id))
17413            || group.iter().any(|id| !colocated.insert(*id))
17414        {
17415            return Err(MongrelError::Schema(
17416                "recovered schema contains invalid column co-location groups".into(),
17417            ));
17418        }
17419    }
17420
17421    let mut constraint_ids = HashSet::new();
17422    let mut constraint_names = HashSet::<String>::new();
17423    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
17424        if name.is_empty()
17425            || !constraint_ids.insert(id)
17426            || !constraint_names.insert(name.to_owned())
17427        {
17428            return Err(MongrelError::Schema(
17429                "recovered schema contains duplicate or empty constraint identities".into(),
17430            ));
17431        }
17432        Ok(())
17433    };
17434    for unique in &schema.constraints.uniques {
17435        validate_constraint_identity(unique.id, &unique.name)?;
17436        if unique.columns.is_empty()
17437            || unique.columns.iter().any(|id| !column_ids.contains(id))
17438            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
17439        {
17440            return Err(MongrelError::Schema(format!(
17441                "unique constraint {:?} has invalid columns",
17442                unique.name
17443            )));
17444        }
17445    }
17446    for foreign_key in &schema.constraints.foreign_keys {
17447        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
17448        if foreign_key.ref_table.is_empty()
17449            || foreign_key.columns.is_empty()
17450            || foreign_key.columns.len() != foreign_key.ref_columns.len()
17451            || foreign_key
17452                .columns
17453                .iter()
17454                .any(|id| !column_ids.contains(id))
17455            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
17456            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
17457                != foreign_key.ref_columns.len()
17458        {
17459            return Err(MongrelError::Schema(format!(
17460                "foreign key {:?} has invalid columns",
17461                foreign_key.name
17462            )));
17463        }
17464        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
17465            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
17466            && foreign_key.columns.iter().any(|id| {
17467                schema
17468                    .columns
17469                    .iter()
17470                    .find(|column| column.id == *id)
17471                    .is_none_or(|column| {
17472                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
17473                    })
17474            })
17475        {
17476            return Err(MongrelError::Schema(format!(
17477                "foreign key {:?} uses SET NULL on a non-nullable column",
17478                foreign_key.name
17479            )));
17480        }
17481    }
17482    for check in &schema.constraints.checks {
17483        validate_constraint_identity(check.id, &check.name)?;
17484        check.expr.validate()?;
17485        validate_check_columns(&check.expr, &column_ids)?;
17486    }
17487    Ok(())
17488}
17489
17490fn validate_check_columns(
17491    expression: &crate::constraint::CheckExpr,
17492    column_ids: &HashSet<u16>,
17493) -> Result<()> {
17494    use crate::constraint::CheckExpr;
17495    match expression {
17496        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
17497            if column_ids.contains(id) {
17498                Ok(())
17499            } else {
17500                Err(MongrelError::Schema(format!(
17501                    "check constraint references unknown column {id}"
17502                )))
17503            }
17504        }
17505        CheckExpr::Regex { col, .. } => {
17506            if column_ids.contains(col) {
17507                Ok(())
17508            } else {
17509                Err(MongrelError::Schema(format!(
17510                    "check constraint references unknown column {col}"
17511                )))
17512            }
17513        }
17514        CheckExpr::Add(left, right)
17515        | CheckExpr::Sub(left, right)
17516        | CheckExpr::Mul(left, right)
17517        | CheckExpr::Div(left, right)
17518        | CheckExpr::Mod(left, right)
17519        | CheckExpr::Eq(left, right)
17520        | CheckExpr::Ne(left, right)
17521        | CheckExpr::Lt(left, right)
17522        | CheckExpr::Le(left, right)
17523        | CheckExpr::Gt(left, right)
17524        | CheckExpr::Ge(left, right)
17525        | CheckExpr::And(left, right)
17526        | CheckExpr::Or(left, right) => {
17527            validate_check_columns(left, column_ids)?;
17528            validate_check_columns(right, column_ids)
17529        }
17530        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
17531        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
17532    }
17533}
17534
17535fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
17536    for (name, prior, candidate) in [
17537        ("db_epoch", current.db_epoch, next.db_epoch),
17538        ("next_table_id", current.next_table_id, next.next_table_id),
17539        (
17540            "next_segment_no",
17541            current.next_segment_no,
17542            next.next_segment_no,
17543        ),
17544        ("next_user_id", current.next_user_id, next.next_user_id),
17545        (
17546            "security_version",
17547            current.security_version,
17548            next.security_version,
17549        ),
17550    ] {
17551        if candidate < prior {
17552            return Err(MongrelError::Schema(format!(
17553                "catalog snapshot rolls back {name} from {prior} to {candidate}"
17554            )));
17555        }
17556    }
17557    for prior in &current.tables {
17558        let Some(candidate) = next
17559            .tables
17560            .iter()
17561            .find(|entry| entry.table_id == prior.table_id)
17562        else {
17563            return Err(MongrelError::Schema(format!(
17564                "catalog snapshot removes table identity {}",
17565                prior.table_id
17566            )));
17567        };
17568        if candidate.created_epoch != prior.created_epoch
17569            || candidate.schema.schema_id < prior.schema.schema_id
17570            || matches!(prior.state, TableState::Dropped { .. })
17571                && !matches!(candidate.state, TableState::Dropped { .. })
17572        {
17573            return Err(MongrelError::Schema(format!(
17574                "catalog snapshot rolls back table identity {}",
17575                prior.table_id
17576            )));
17577        }
17578    }
17579    for prior in &current.users {
17580        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
17581            if candidate.username != prior.username
17582                || candidate.created_epoch != prior.created_epoch
17583            {
17584                return Err(MongrelError::Schema(format!(
17585                    "catalog snapshot reuses user identity {}",
17586                    prior.id
17587                )));
17588            }
17589        }
17590    }
17591    Ok(())
17592}
17593
17594fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
17595    let mut table_ids = HashSet::new();
17596    let mut active_names = HashSet::new();
17597    let mut max_table_id = None::<u64>;
17598    for entry in &catalog.tables {
17599        if !table_ids.insert(entry.table_id) {
17600            return Err(MongrelError::Schema(format!(
17601                "catalog contains duplicate table id {}",
17602                entry.table_id
17603            )));
17604        }
17605        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
17606        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
17607            return Err(MongrelError::Schema(format!(
17608                "catalog table {} has invalid name or creation epoch",
17609                entry.table_id
17610            )));
17611        }
17612        validate_recovered_schema(&entry.schema)?;
17613        match &entry.state {
17614            TableState::Live => {
17615                if !active_names.insert(entry.name.as_str()) {
17616                    return Err(MongrelError::Schema(format!(
17617                        "catalog contains duplicate active table name {:?}",
17618                        entry.name
17619                    )));
17620                }
17621            }
17622            TableState::Dropped { at_epoch } => {
17623                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
17624                    return Err(MongrelError::Schema(format!(
17625                        "catalog table {} has invalid drop epoch {at_epoch}",
17626                        entry.table_id
17627                    )));
17628                }
17629            }
17630            TableState::Building {
17631                intended_name,
17632                query_id,
17633                replaces_table_id,
17634                ..
17635            } => {
17636                if intended_name.is_empty() || query_id.is_empty() {
17637                    return Err(MongrelError::Schema(format!(
17638                        "building table {} has empty identity fields",
17639                        entry.table_id
17640                    )));
17641                }
17642                if !active_names.insert(entry.name.as_str()) {
17643                    return Err(MongrelError::Schema(format!(
17644                        "catalog contains duplicate active/building table name {:?}",
17645                        entry.name
17646                    )));
17647                }
17648                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
17649                    return Err(MongrelError::Schema(
17650                        "building table cannot replace itself".into(),
17651                    ));
17652                }
17653            }
17654        }
17655    }
17656    if let Some(maximum) = max_table_id {
17657        let required = maximum
17658            .checked_add(1)
17659            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
17660        if catalog.next_table_id < required {
17661            return Err(MongrelError::Schema(format!(
17662                "catalog next_table_id {} precedes required {required}",
17663                catalog.next_table_id
17664            )));
17665        }
17666    }
17667    for entry in &catalog.tables {
17668        if let TableState::Building {
17669            replaces_table_id: Some(replaced),
17670            ..
17671        } = entry.state
17672        {
17673            if !table_ids.contains(&replaced) {
17674                return Err(MongrelError::Schema(format!(
17675                    "building table {} replaces unknown table {replaced}",
17676                    entry.table_id
17677                )));
17678            }
17679        }
17680    }
17681    for entry in &catalog.tables {
17682        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
17683            validate_foreign_key_targets(catalog, &entry.schema)?;
17684        }
17685    }
17686
17687    let mut external_names = HashSet::new();
17688    for entry in &catalog.external_tables {
17689        entry.validate()?;
17690        validate_recovered_schema(&entry.declared_schema)?;
17691        if !entry.declared_schema.constraints.is_empty() {
17692            return Err(MongrelError::Schema(format!(
17693                "external table {:?} cannot carry engine-enforced constraints",
17694                entry.name
17695            )));
17696        }
17697        if entry.created_epoch > catalog.db_epoch
17698            || !external_names.insert(entry.name.as_str())
17699            || active_names.contains(entry.name.as_str())
17700        {
17701            return Err(MongrelError::Schema(format!(
17702                "invalid or duplicate external table {:?}",
17703                entry.name
17704            )));
17705        }
17706    }
17707
17708    let mut procedure_names = HashSet::new();
17709    for entry in &catalog.procedures {
17710        entry.procedure.validate()?;
17711        if entry.procedure.created_epoch > entry.procedure.updated_epoch
17712            || entry.procedure.updated_epoch > catalog.db_epoch
17713            || !procedure_names.insert(entry.procedure.name.as_str())
17714        {
17715            return Err(MongrelError::Schema(format!(
17716                "invalid or duplicate procedure {:?}",
17717                entry.procedure.name
17718            )));
17719        }
17720        validate_recovered_procedure_references(catalog, &entry.procedure)?;
17721    }
17722
17723    let mut trigger_names = HashSet::new();
17724    for entry in &catalog.triggers {
17725        entry.trigger.validate()?;
17726        if entry.trigger.created_epoch > entry.trigger.updated_epoch
17727            || entry.trigger.updated_epoch > catalog.db_epoch
17728            || !trigger_names.insert(entry.trigger.name.as_str())
17729        {
17730            return Err(MongrelError::Schema(format!(
17731                "invalid or duplicate trigger {:?}",
17732                entry.trigger.name
17733            )));
17734        }
17735        validate_recovered_trigger_references(catalog, &entry.trigger)?;
17736    }
17737
17738    let mut views = HashSet::new();
17739    for view in &catalog.materialized_views {
17740        let target = catalog.live(&view.name).ok_or_else(|| {
17741            MongrelError::Schema(format!(
17742                "materialized view {:?} has no live table",
17743                view.name
17744            ))
17745        })?;
17746        if view.name.is_empty()
17747            || view.query.trim().is_empty()
17748            || view.last_refresh_epoch > catalog.db_epoch
17749            || !views.insert(view.name.as_str())
17750        {
17751            return Err(MongrelError::Schema(format!(
17752                "materialized view {:?} has no unique live table",
17753                view.name
17754            )));
17755        }
17756        if let Some(incremental) = &view.incremental {
17757            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
17758                MongrelError::Schema(format!(
17759                    "materialized view {:?} references missing source {:?}",
17760                    view.name, incremental.source_table
17761                ))
17762            })?;
17763            if source.table_id != incremental.source_table_id
17764                || source
17765                    .schema
17766                    .columns
17767                    .iter()
17768                    .all(|column| column.id != incremental.group_column)
17769            {
17770                return Err(MongrelError::Schema(format!(
17771                    "materialized view {:?} has invalid incremental source",
17772                    view.name
17773                )));
17774            }
17775            let target_ids = target
17776                .schema
17777                .columns
17778                .iter()
17779                .map(|column| column.id)
17780                .collect::<HashSet<_>>();
17781            let mut output_ids = HashSet::new();
17782            let count_outputs = incremental
17783                .outputs
17784                .iter()
17785                .filter(|output| {
17786                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
17787                })
17788                .count();
17789            if incremental.checkpoint_event_id.is_empty()
17790                || !target_ids.contains(&incremental.group_output_column)
17791                || !target_ids.contains(&incremental.count_output_column)
17792                || incremental.outputs.is_empty()
17793                || count_outputs != 1
17794                || incremental.outputs.iter().any(|output| {
17795                    !target_ids.contains(&output.output_column)
17796                        || output.output_column == incremental.group_output_column
17797                        || !output_ids.insert(output.output_column)
17798                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
17799                            && output.output_column != incremental.count_output_column
17800                        || match output.kind {
17801                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
17802                                source
17803                                    .schema
17804                                    .columns
17805                                    .iter()
17806                                    .all(|column| column.id != source_column)
17807                            }
17808                            crate::catalog::IncrementalAggregateKind::Count => false,
17809                        }
17810                })
17811            {
17812                return Err(MongrelError::Schema(format!(
17813                    "materialized view {:?} has invalid incremental outputs",
17814                    view.name
17815                )));
17816            }
17817        }
17818    }
17819
17820    validate_security_catalog(catalog, &catalog.security)?;
17821    validate_recovered_auth_catalog(catalog)?;
17822    Ok(())
17823}
17824
17825fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
17826    let mut changed = false;
17827    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
17828        let required = maximum
17829            .checked_add(1)
17830            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
17831        if catalog.next_table_id < required {
17832            catalog.next_table_id = required;
17833            changed = true;
17834        }
17835    }
17836    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
17837        let required = maximum
17838            .checked_add(1)
17839            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
17840        if catalog.next_user_id < required {
17841            catalog.next_user_id = required;
17842            changed = true;
17843        }
17844    }
17845    Ok(changed)
17846}
17847
17848fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
17849    for foreign_key in &schema.constraints.foreign_keys {
17850        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
17851            MongrelError::Schema(format!(
17852                "foreign key {:?} references unknown live table {:?}",
17853                foreign_key.name, foreign_key.ref_table
17854            ))
17855        })?;
17856        let referenced_unique = parent
17857            .schema
17858            .constraints
17859            .uniques
17860            .iter()
17861            .any(|unique| unique.columns == foreign_key.ref_columns)
17862            || foreign_key.ref_columns.len() == 1
17863                && parent
17864                    .schema
17865                    .primary_key()
17866                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
17867        if !referenced_unique {
17868            return Err(MongrelError::Schema(format!(
17869                "foreign key {:?} does not reference a unique key",
17870                foreign_key.name
17871            )));
17872        }
17873        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
17874            let local = schema.columns.iter().find(|column| column.id == *local_id);
17875            let referenced = parent
17876                .schema
17877                .columns
17878                .iter()
17879                .find(|column| column.id == *parent_id);
17880            if local
17881                .zip(referenced)
17882                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
17883            {
17884                return Err(MongrelError::Schema(format!(
17885                    "foreign key {:?} has missing or incompatible columns",
17886                    foreign_key.name
17887                )));
17888            }
17889        }
17890    }
17891    Ok(())
17892}
17893
17894fn validate_recovered_procedure_references(
17895    catalog: &Catalog,
17896    procedure: &StoredProcedure,
17897) -> Result<()> {
17898    for step in &procedure.body.steps {
17899        let Some(table_name) = step.table() else {
17900            continue;
17901        };
17902        let schema = &catalog
17903            .live(table_name)
17904            .ok_or_else(|| {
17905                MongrelError::Schema(format!(
17906                    "procedure {:?} references unknown table {table_name:?}",
17907                    procedure.name
17908                ))
17909            })?
17910            .schema;
17911        match step {
17912            ProcedureStep::NativeQuery {
17913                conditions,
17914                projection,
17915                ..
17916            } => {
17917                for condition in conditions {
17918                    validate_condition_columns(condition, schema)?;
17919                }
17920                for id in projection.iter().flatten() {
17921                    validate_column_id(*id, schema)?;
17922                }
17923            }
17924            ProcedureStep::Put { cells, .. } => {
17925                for cell in cells {
17926                    validate_column_id(cell.column_id, schema)?;
17927                }
17928            }
17929            ProcedureStep::Upsert {
17930                cells,
17931                update_cells,
17932                ..
17933            } => {
17934                for cell in cells.iter().chain(update_cells.iter().flatten()) {
17935                    validate_column_id(cell.column_id, schema)?;
17936                }
17937            }
17938            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
17939                return Err(MongrelError::Schema(format!(
17940                    "procedure {:?} deletes by primary key on table without one",
17941                    procedure.name
17942                )));
17943            }
17944            ProcedureStep::DeleteByPk { .. }
17945            | ProcedureStep::DeleteRows { .. }
17946            | ProcedureStep::SqlQuery { .. } => {}
17947        }
17948    }
17949    Ok(())
17950}
17951
17952fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
17953    let target_schema = match &trigger.target {
17954        TriggerTarget::Table(name) => catalog
17955            .live(name)
17956            .ok_or_else(|| {
17957                MongrelError::Schema(format!(
17958                    "trigger {:?} references unknown table {name:?}",
17959                    trigger.name
17960                ))
17961            })?
17962            .schema
17963            .clone(),
17964        TriggerTarget::View(_) => Schema {
17965            columns: trigger.target_columns.clone(),
17966            ..Schema::default()
17967        },
17968    };
17969    for column in &trigger.update_of {
17970        if target_schema.column(column).is_none() {
17971            return Err(MongrelError::Schema(format!(
17972                "trigger {:?} references unknown UPDATE OF column {column:?}",
17973                trigger.name
17974            )));
17975        }
17976    }
17977    if let Some(expr) = &trigger.when {
17978        validate_trigger_expr(expr, &target_schema, trigger.event)?;
17979    }
17980    let mut selects = HashMap::new();
17981    for step in &trigger.program.steps {
17982        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
17983            return Err(MongrelError::Schema(
17984                "SetNew is only valid in BEFORE triggers".into(),
17985            ));
17986        }
17987        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
17988    }
17989    Ok(())
17990}
17991
17992fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
17993    let mut role_names = HashSet::new();
17994    for role in &catalog.roles {
17995        if role.name.is_empty()
17996            || role.created_epoch > catalog.db_epoch
17997            || !role_names.insert(role.name.as_str())
17998        {
17999            return Err(MongrelError::Schema(format!(
18000                "invalid or duplicate role {:?}",
18001                role.name
18002            )));
18003        }
18004        for permission in &role.permissions {
18005            if let Some(table) = permission_table(permission) {
18006                let schema = catalog
18007                    .live(table)
18008                    .map(|entry| &entry.schema)
18009                    .or_else(|| {
18010                        catalog
18011                            .external_tables
18012                            .iter()
18013                            .find(|entry| entry.name == table)
18014                            .map(|entry| &entry.declared_schema)
18015                    })
18016                    .ok_or_else(|| {
18017                        MongrelError::Schema(format!(
18018                            "role {:?} references unknown table {table:?}",
18019                            role.name
18020                        ))
18021                    })?;
18022                let columns = match permission {
18023                    crate::auth::Permission::SelectColumns { columns, .. }
18024                    | crate::auth::Permission::InsertColumns { columns, .. }
18025                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
18026                    _ => None,
18027                };
18028                if columns.is_some_and(|columns| {
18029                    columns.is_empty()
18030                        || columns.iter().any(|column| schema.column(column).is_none())
18031                }) {
18032                    return Err(MongrelError::Schema(format!(
18033                        "role {:?} contains invalid column permissions",
18034                        role.name
18035                    )));
18036                }
18037            }
18038        }
18039    }
18040    let mut user_ids = HashSet::new();
18041    let mut usernames = HashSet::new();
18042    let mut maximum_user_id = 0;
18043    for user in &catalog.users {
18044        maximum_user_id = maximum_user_id.max(user.id);
18045        if user.id == 0
18046            || user.username.is_empty()
18047            || user.password_hash.is_empty()
18048            || user.created_epoch > catalog.db_epoch
18049            || !user_ids.insert(user.id)
18050            || !usernames.insert(user.username.as_str())
18051            || user
18052                .roles
18053                .iter()
18054                .any(|role| !role_names.contains(role.as_str()))
18055        {
18056            return Err(MongrelError::Schema(format!(
18057                "invalid or duplicate user {:?}",
18058                user.username
18059            )));
18060        }
18061    }
18062    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
18063        return Err(MongrelError::Schema(
18064            "catalog next_user_id does not advance beyond existing user ids".into(),
18065        ));
18066    }
18067    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
18068        return Err(MongrelError::Schema(
18069            "authenticated catalog has no administrator".into(),
18070        ));
18071    }
18072    Ok(())
18073}
18074
18075fn validate_recovered_storage_plan(
18076    root: &Path,
18077    durable_root: Option<&crate::durable_file::DurableRoot>,
18078    catalog: &Catalog,
18079    created_table_ids: &HashSet<u64>,
18080    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
18081    meta_dek: Option<&[u8; META_DEK_LEN]>,
18082) -> Result<Vec<u64>> {
18083    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
18084    let mut reconcile = Vec::new();
18085    for entry in &catalog.tables {
18086        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18087            continue;
18088        }
18089        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
18090        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
18091        let table_exists = match durable_root {
18092            Some(root) => match root.open_directory(&relative_dir) {
18093                Ok(_) => true,
18094                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
18095                Err(error) => return Err(error.into()),
18096            },
18097            None => table_dir.is_dir(),
18098        };
18099        if !table_exists {
18100            if created_table_ids.contains(&entry.table_id) {
18101                reconcile.push(entry.table_id);
18102                continue;
18103            }
18104            return Err(MongrelError::NotFound(format!(
18105                "catalog table {} storage is missing",
18106                entry.table_id
18107            )));
18108        }
18109        let manifest_result = match durable_root {
18110            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
18111            None => crate::manifest::read(&table_dir, meta_dek),
18112        };
18113        let manifest = match manifest_result {
18114            Ok(manifest) => manifest,
18115            Err(MongrelError::Io(error))
18116                if created_table_ids.contains(&entry.table_id)
18117                    && error.kind() == std::io::ErrorKind::NotFound =>
18118            {
18119                reconcile.push(entry.table_id);
18120                continue;
18121            }
18122            Err(error) => return Err(error),
18123        };
18124        if manifest.table_id != entry.table_id {
18125            return Err(MongrelError::Conflict(format!(
18126                "catalog table {} storage identity mismatch",
18127                entry.table_id
18128            )));
18129        }
18130        let schema_result = match durable_root {
18131            Some(root) => root
18132                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
18133                .map_err(MongrelError::from),
18134            None => crate::durable_file::open_regular_nofollow(
18135                &table_dir.join(crate::engine::SCHEMA_FILENAME),
18136            ),
18137        };
18138        let file = match schema_result {
18139            Ok(file) => file,
18140            Err(MongrelError::Io(error))
18141                if created_table_ids.contains(&entry.table_id)
18142                    && error.kind() == std::io::ErrorKind::NotFound =>
18143            {
18144                reconcile.push(entry.table_id);
18145                continue;
18146            }
18147            Err(error) => return Err(error),
18148        };
18149        let length = file.metadata()?.len();
18150        if length > MAX_SCHEMA_BYTES {
18151            return Err(MongrelError::ResourceLimitExceeded {
18152                resource: "recovered schema bytes",
18153                requested: usize::try_from(length).unwrap_or(usize::MAX),
18154                limit: MAX_SCHEMA_BYTES as usize,
18155            });
18156        }
18157        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
18158            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
18159        if manifest.schema_id != entry.schema.schema_id
18160            || crate::wal::DdlOp::encode_schema(&disk_schema)?
18161                != crate::wal::DdlOp::encode_schema(&entry.schema)?
18162        {
18163            reconcile.push(entry.table_id);
18164        }
18165    }
18166    for table_id in ttl_updates.keys() {
18167        if !catalog.tables.iter().any(|entry| {
18168            entry.table_id == *table_id
18169                && matches!(entry.state, TableState::Live | TableState::Building { .. })
18170        }) {
18171            continue;
18172        }
18173        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
18174        let table_exists = match durable_root {
18175            Some(root) => match root.open_directory(&relative_dir) {
18176                Ok(_) => true,
18177                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
18178                Err(error) => return Err(error.into()),
18179            },
18180            None => root.join(&relative_dir).is_dir(),
18181        };
18182        if !table_exists && !created_table_ids.contains(table_id) {
18183            return Err(MongrelError::NotFound(format!(
18184                "TTL recovery table {table_id} storage is missing"
18185            )));
18186        }
18187    }
18188    reconcile.sort_unstable();
18189    reconcile.dedup();
18190    Ok(reconcile)
18191}
18192
18193fn validate_catalog_table_storage(
18194    root: &crate::durable_file::DurableRoot,
18195    catalog: &Catalog,
18196    meta_dek: Option<&[u8; META_DEK_LEN]>,
18197) -> Result<()> {
18198    for entry in &catalog.tables {
18199        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18200            continue;
18201        }
18202        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
18203        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
18204        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
18205            return Err(MongrelError::Conflict(format!(
18206                "catalog table {} storage identity mismatch",
18207                entry.table_id
18208            )));
18209        }
18210        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
18211    }
18212    Ok(())
18213}
18214
18215fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
18216    match schema.columns.iter_mut().find(|c| c.id == column.id) {
18217        Some(existing) if *existing == column => Ok(false),
18218        Some(existing) => {
18219            *existing = column;
18220            schema.schema_id = schema
18221                .schema_id
18222                .checked_add(1)
18223                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
18224            Ok(true)
18225        }
18226        None => {
18227            schema.columns.push(column);
18228            schema.schema_id = schema
18229                .schema_id
18230                .checked_add(1)
18231                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
18232            Ok(true)
18233        }
18234    }
18235}
18236
18237fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
18238    use crate::auth::Permission;
18239    match permission {
18240        Permission::Select { table }
18241        | Permission::Insert { table }
18242        | Permission::Update { table }
18243        | Permission::Delete { table }
18244        | Permission::SelectColumns { table, .. }
18245        | Permission::InsertColumns { table, .. }
18246        | Permission::UpdateColumns { table, .. } => Some(table),
18247        Permission::All | Permission::Ddl | Permission::Admin => None,
18248    }
18249}
18250
18251fn apply_rebuilding_publish(
18252    catalog: &mut Catalog,
18253    table_id: u64,
18254    replaced_table_id: u64,
18255    new_name: &str,
18256    epoch: u64,
18257) -> Result<bool> {
18258    let already_published = catalog.tables.iter().any(|entry| {
18259        entry.table_id == table_id
18260            && entry.name == new_name
18261            && matches!(entry.state, TableState::Live)
18262    }) && catalog.tables.iter().any(|entry| {
18263        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
18264    });
18265    if already_published {
18266        return Ok(false);
18267    }
18268    let schema = catalog
18269        .tables
18270        .iter()
18271        .find(|entry| entry.table_id == table_id)
18272        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
18273        .schema
18274        .clone();
18275    let replaced = catalog
18276        .tables
18277        .iter_mut()
18278        .find(|entry| entry.table_id == replaced_table_id)
18279        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
18280    replaced.state = TableState::Dropped { at_epoch: epoch };
18281    let replacement = catalog
18282        .tables
18283        .iter_mut()
18284        .find(|entry| entry.table_id == table_id)
18285        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
18286    replacement.name = new_name.to_string();
18287    replacement.state = TableState::Live;
18288
18289    for role in &mut catalog.roles {
18290        role.permissions.retain_mut(|permission| {
18291            retain_rebuilt_permission_columns(permission, new_name, &schema)
18292        });
18293    }
18294    for definition in &mut catalog.materialized_views {
18295        if let Some(incremental) = definition.incremental.as_mut() {
18296            if incremental.source_table == new_name
18297                && incremental.source_table_id == replaced_table_id
18298            {
18299                incremental.source_table_id = table_id;
18300            }
18301        }
18302    }
18303    advance_security_version(catalog)?;
18304    Ok(true)
18305}
18306
18307fn retain_rebuilt_permission_columns(
18308    permission: &mut crate::auth::Permission,
18309    target_table: &str,
18310    schema: &Schema,
18311) -> bool {
18312    use crate::auth::Permission;
18313    let columns = match permission {
18314        Permission::SelectColumns { table, columns }
18315        | Permission::InsertColumns { table, columns }
18316        | Permission::UpdateColumns { table, columns }
18317            if table == target_table =>
18318        {
18319            Some(columns)
18320        }
18321        _ => None,
18322    };
18323    if let Some(columns) = columns {
18324        columns.retain(|column| schema.column(column).is_some());
18325        !columns.is_empty()
18326    } else {
18327        true
18328    }
18329}
18330
18331fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
18332    use crate::auth::Permission;
18333    let table = match permission {
18334        Permission::Select { table }
18335        | Permission::Insert { table }
18336        | Permission::Update { table }
18337        | Permission::Delete { table }
18338        | Permission::SelectColumns { table, .. }
18339        | Permission::InsertColumns { table, .. }
18340        | Permission::UpdateColumns { table, .. } => Some(table),
18341        Permission::All | Permission::Ddl | Permission::Admin => None,
18342    };
18343    if let Some(table) = table.filter(|table| table.as_str() == old) {
18344        *table = new.to_string();
18345    }
18346}
18347
18348fn rename_permission_column(
18349    permission: &mut crate::auth::Permission,
18350    target_table: &str,
18351    old: &str,
18352    new: &str,
18353) {
18354    use crate::auth::Permission;
18355    let columns = match permission {
18356        Permission::SelectColumns { table, columns }
18357        | Permission::InsertColumns { table, columns }
18358        | Permission::UpdateColumns { table, columns }
18359            if table == target_table =>
18360        {
18361            Some(columns)
18362        }
18363        _ => None,
18364    };
18365    if let Some(column) = columns
18366        .into_iter()
18367        .flatten()
18368        .find(|column| column.as_str() == old)
18369    {
18370        *column = new.to_string();
18371    }
18372}
18373
18374pub(crate) fn validate_security_catalog(
18375    catalog: &Catalog,
18376    security: &crate::security::SecurityCatalog,
18377) -> Result<()> {
18378    let mut policy_names = HashSet::new();
18379    for table in &security.rls_tables {
18380        if catalog.live(table).is_none() {
18381            return Err(MongrelError::NotFound(format!(
18382                "RLS table {table:?} not found"
18383            )));
18384        }
18385    }
18386    for policy in &security.policies {
18387        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
18388            return Err(MongrelError::InvalidArgument(format!(
18389                "duplicate policy {:?} on {:?}",
18390                policy.name, policy.table
18391            )));
18392        }
18393        let schema = &catalog
18394            .live(&policy.table)
18395            .ok_or_else(|| {
18396                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
18397            })?
18398            .schema;
18399        if let Some(expression) = &policy.using {
18400            validate_security_expression(expression, schema)?;
18401        }
18402        if let Some(expression) = &policy.with_check {
18403            validate_security_expression(expression, schema)?;
18404        }
18405    }
18406    let mut mask_names = HashSet::new();
18407    for mask in &security.masks {
18408        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
18409            return Err(MongrelError::InvalidArgument(format!(
18410                "duplicate mask {:?} on {:?}",
18411                mask.name, mask.table
18412            )));
18413        }
18414        let column = catalog
18415            .live(&mask.table)
18416            .and_then(|entry| {
18417                entry
18418                    .schema
18419                    .columns
18420                    .iter()
18421                    .find(|column| column.id == mask.column)
18422            })
18423            .ok_or_else(|| {
18424                MongrelError::NotFound(format!(
18425                    "mask column {} on {:?} not found",
18426                    mask.column, mask.table
18427                ))
18428            })?;
18429        if matches!(
18430            mask.strategy,
18431            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
18432        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
18433        {
18434            return Err(MongrelError::InvalidArgument(format!(
18435                "mask {:?} requires a string/bytes column",
18436                mask.name
18437            )));
18438        }
18439    }
18440    Ok(())
18441}
18442
18443fn validate_security_expression(
18444    expression: &crate::security::SecurityExpr,
18445    schema: &Schema,
18446) -> Result<()> {
18447    use crate::security::SecurityExpr;
18448    match expression {
18449        SecurityExpr::True => Ok(()),
18450        SecurityExpr::ColumnEqCurrentUser { column }
18451        | SecurityExpr::ColumnEqValue { column, .. } => {
18452            if schema
18453                .columns
18454                .iter()
18455                .any(|candidate| candidate.id == *column)
18456            {
18457                Ok(())
18458            } else {
18459                Err(MongrelError::InvalidArgument(format!(
18460                    "security expression references unknown column id {column}"
18461                )))
18462            }
18463        }
18464        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
18465            validate_security_expression(left, schema)?;
18466            validate_security_expression(right, schema)
18467        }
18468        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
18469    }
18470}
18471
18472/// Remove canonical numeric table directories that no catalog generation owns.
18473fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
18474    let referenced = cat
18475        .tables
18476        .iter()
18477        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
18478        .map(|entry| entry.table_id)
18479        .collect::<HashSet<_>>();
18480    let tables_dir = root.join(TABLES_DIR);
18481    let entries = match std::fs::read_dir(&tables_dir) {
18482        Ok(entries) => entries,
18483        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
18484        Err(error) => return Err(error.into()),
18485    };
18486    for entry in entries {
18487        let entry = entry?;
18488        if !entry.file_type()?.is_dir() {
18489            continue;
18490        }
18491        let file_name = entry.file_name();
18492        let Some(name) = file_name.to_str() else {
18493            continue;
18494        };
18495        let Ok(table_id) = name.parse::<u64>() else {
18496            continue;
18497        };
18498        if name != table_id.to_string() {
18499            continue;
18500        }
18501        if !referenced.contains(&table_id) {
18502            crate::durable_file::remove_directory_all(&entry.path())?;
18503        }
18504    }
18505    Ok(())
18506}
18507
18508/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
18509/// #14). These dirs hold pending uniform-epoch runs from large transactions
18510/// that were aborted or crashed before commit. On open, all such dirs are safe
18511/// to remove because committed txns moved their runs to `_runs/` at publish.
18512fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
18513    for entry in &cat.tables {
18514        let txn_dir = root
18515            .join(TABLES_DIR)
18516            .join(entry.table_id.to_string())
18517            .join("_txn");
18518        if txn_dir.exists() {
18519            let _ = std::fs::remove_dir_all(&txn_dir);
18520        }
18521    }
18522}
18523
18524#[cfg(test)]
18525mod write_permission_tests {
18526    use super::*;
18527    use crate::txn::Staged;
18528
18529    struct NoopExternalBridge;
18530
18531    impl ExternalTriggerBridge for NoopExternalBridge {
18532        fn apply_trigger_external_write(
18533            &self,
18534            _entry: &ExternalTableEntry,
18535            base_state: Vec<u8>,
18536            _op: ExternalTriggerWrite,
18537        ) -> Result<ExternalTriggerWriteResult> {
18538            Ok(ExternalTriggerWriteResult::new(base_state))
18539        }
18540    }
18541
18542    fn assert_txn_namespace_full<T>(result: Result<T>) {
18543        assert!(matches!(result, Err(MongrelError::Full(_))));
18544    }
18545
18546    #[test]
18547    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
18548        let directory = tempfile::tempdir().unwrap();
18549        let database = Database::create(directory.path()).unwrap();
18550        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
18551        *database.next_txn_id.lock() = generation << 32;
18552        let before = crate::wal::SharedWal::replay(directory.path())
18553            .unwrap()
18554            .len();
18555        let bridge = NoopExternalBridge;
18556
18557        assert_txn_namespace_full(database.begin().commit());
18558        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
18559        assert_txn_namespace_full(
18560            database
18561                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
18562                .commit(),
18563        );
18564        assert_txn_namespace_full(
18565            database
18566                .begin_with_external_trigger_bridge(&bridge)
18567                .commit(),
18568        );
18569        assert_txn_namespace_full(
18570            database
18571                .begin_with_external_trigger_bridge_as(&bridge, None)
18572                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
18573        );
18574
18575        assert_eq!(
18576            crate::wal::SharedWal::replay(directory.path())
18577                .unwrap()
18578                .len(),
18579            before
18580        );
18581        drop(database);
18582        Database::open(directory.path()).unwrap();
18583    }
18584
18585    #[test]
18586    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
18587        let directory = tempfile::tempdir().unwrap();
18588        let table_dir = directory.path().join("7");
18589        crate::durable_file::create_directory_all(&table_dir).unwrap();
18590        let original_schema = test_schema();
18591        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
18592        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
18593        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
18594        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
18595        let original_bytes = std::fs::read(&schema_path).unwrap();
18596
18597        let mut replacement_schema = original_schema;
18598        replacement_schema.schema_id += 1;
18599        assert!(matches!(
18600            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
18601            Err(MongrelError::Conflict(_))
18602        ));
18603
18604        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
18605        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
18606        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
18607        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
18608    }
18609
18610    #[test]
18611    fn catalog_table_missing_storage_fails_without_recreating_it() {
18612        let directory = tempfile::tempdir().unwrap();
18613        let table_dir = {
18614            let database = Database::create(directory.path()).unwrap();
18615            database.create_table("docs", test_schema()).unwrap();
18616            directory
18617                .path()
18618                .join(TABLES_DIR)
18619                .join(database.table_id("docs").unwrap().to_string())
18620        };
18621        std::fs::remove_dir_all(&table_dir).unwrap();
18622
18623        assert!(matches!(
18624            Database::open(directory.path()),
18625            Err(MongrelError::NotFound(_))
18626        ));
18627        assert!(!table_dir.exists());
18628    }
18629
18630    #[test]
18631    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
18632        let directory = tempfile::tempdir().unwrap();
18633        let database = std::sync::Arc::new(
18634            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
18635        );
18636        database.create_user("alice", "old-password").unwrap();
18637        let old_identity = database.user_identity("alice").unwrap();
18638        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
18639        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
18640        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
18641        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
18642
18643        std::thread::scope(|scope| {
18644            let authenticate = {
18645                let database = std::sync::Arc::clone(&database);
18646                scope.spawn(move || {
18647                    database.authenticate_principal_inner("alice", "old-password", || {
18648                        verified_tx.send(()).unwrap();
18649                        resume_rx.recv().unwrap();
18650                    })
18651                })
18652            };
18653            verified_rx.recv().unwrap();
18654            let mutate = {
18655                let database = std::sync::Arc::clone(&database);
18656                scope.spawn(move || {
18657                    mutation_started_tx.send(()).unwrap();
18658                    database.drop_user("alice").unwrap();
18659                    database.create_user("alice", "new-password").unwrap();
18660                    mutation_done_tx.send(()).unwrap();
18661                })
18662            };
18663            mutation_started_rx.recv().unwrap();
18664            assert!(mutation_done_rx
18665                .recv_timeout(std::time::Duration::from_millis(50))
18666                .is_err());
18667            resume_tx.send(()).unwrap();
18668            let principal = authenticate.join().unwrap().unwrap().unwrap();
18669            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
18670            mutate.join().unwrap();
18671        });
18672
18673        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
18674        assert!(database
18675            .authenticate_principal("alice", "old-password")
18676            .unwrap()
18677            .is_none());
18678        assert!(database
18679            .authenticate_principal("alice", "new-password")
18680            .unwrap()
18681            .is_some());
18682    }
18683
18684    #[test]
18685    fn homogeneous_batch_summarizes_to_one_permission_decision() {
18686        let staging = (0..10_050)
18687            .map(|_| {
18688                (
18689                    7,
18690                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
18691                )
18692            })
18693            .collect::<Vec<_>>();
18694
18695        let needs = summarize_write_permissions(&staging);
18696        let table = needs.get(&7).unwrap();
18697        assert_eq!(needs.len(), 1);
18698        assert!(table.insert);
18699        assert_eq!(table.insert_columns, [1, 2]);
18700        assert!(!table.update);
18701        assert!(!table.delete);
18702        assert!(!table.truncate);
18703    }
18704
18705    #[test]
18706    fn mixed_writes_union_columns_and_preserve_empty_operations() {
18707        let staging = vec![
18708            (7, Staged::Put(vec![(2, Value::Int64(2))])),
18709            (7, Staged::Put(vec![(1, Value::Int64(1))])),
18710            (
18711                7,
18712                Staged::Update {
18713                    row_id: RowId(1),
18714                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
18715                    changed_columns: vec![2],
18716                },
18717            ),
18718            (7, Staged::Delete(RowId(2))),
18719            (8, Staged::Truncate),
18720        ];
18721
18722        let needs = summarize_write_permissions(&staging);
18723        let table = needs.get(&7).unwrap();
18724        assert_eq!(table.insert_columns, [1, 2]);
18725        assert!(table.update);
18726        assert_eq!(table.update_columns, [2]);
18727        assert!(table.delete);
18728        assert!(needs.get(&8).unwrap().truncate);
18729    }
18730
18731    #[test]
18732    fn final_permission_decisions_do_not_scale_with_rows() {
18733        let credentialless_dir = tempfile::tempdir().unwrap();
18734        let credentialless = Database::create(credentialless_dir.path()).unwrap();
18735        credentialless.create_table("docs", test_schema()).unwrap();
18736        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18737        credentialless
18738            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
18739            .unwrap();
18740        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
18741
18742        let authenticated_dir = tempfile::tempdir().unwrap();
18743        let authenticated =
18744            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
18745                .unwrap();
18746        authenticated.create_table("docs", test_schema()).unwrap();
18747        let admin = authenticated.resolve_principal("admin").unwrap();
18748        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18749        authenticated
18750            .validate_write_permissions(
18751                &puts(authenticated.table_id("docs").unwrap()),
18752                Some(&admin),
18753                None,
18754            )
18755            .unwrap();
18756        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
18757    }
18758
18759    #[test]
18760    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
18761        let dir = tempfile::tempdir().unwrap();
18762        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
18763        db.create_table("docs", test_schema()).unwrap();
18764        let admin = db.resolve_principal("admin").unwrap();
18765        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18766
18767        let mut transaction = db.begin_as(Some(admin));
18768        transaction
18769            .delete_batch("docs", (0..100).map(RowId).collect())
18770            .unwrap();
18771        transaction.commit().unwrap();
18772
18773        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
18774    }
18775
18776    #[test]
18777    fn truncate_validation_checks_admin_once_for_all_tables() {
18778        let dir = tempfile::tempdir().unwrap();
18779        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
18780        db.create_table("first", test_schema()).unwrap();
18781        db.create_table("second", test_schema()).unwrap();
18782        let admin = db.resolve_principal("admin").unwrap();
18783        let staging = vec![
18784            (db.table_id("first").unwrap(), Staged::Truncate),
18785            (db.table_id("second").unwrap(), Staged::Truncate),
18786        ];
18787
18788        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18789        db.validate_write_permissions(&staging, Some(&admin), None)
18790            .unwrap();
18791        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
18792    }
18793
18794    #[test]
18795    fn one_table_commit_batches_structural_work() {
18796        let dir = tempfile::tempdir().unwrap();
18797        let db = Database::create(dir.path()).unwrap();
18798        db.create_table("docs", test_schema()).unwrap();
18799        let table_id = db.table_id("docs").unwrap();
18800
18801        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
18802        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
18803        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
18804        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
18805        db.transaction(|transaction| {
18806            for id in 0..100 {
18807                transaction.put("docs", vec![(1, Value::Int64(id))])?;
18808            }
18809            Ok(())
18810        })
18811        .unwrap();
18812
18813        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
18814        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18815        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18816        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
18817
18818        let puts = crate::wal::SharedWal::replay(dir.path())
18819            .unwrap()
18820            .into_iter()
18821            .filter_map(|record| match record.op {
18822                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
18823                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
18824                        .unwrap()
18825                        .len(),
18826                ),
18827                _ => None,
18828            })
18829            .collect::<Vec<_>>();
18830        assert_eq!(puts, [100]);
18831
18832        let row_ids = db
18833            .table("docs")
18834            .unwrap()
18835            .lock()
18836            .visible_rows(db.snapshot().0)
18837            .unwrap()
18838            .into_iter()
18839            .take(2)
18840            .map(|row| row.row_id)
18841            .collect::<Vec<_>>();
18842        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
18843        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
18844        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
18845        db.transaction(|transaction| {
18846            for row_id in row_ids {
18847                transaction.delete("docs", row_id)?;
18848            }
18849            Ok(())
18850        })
18851        .unwrap();
18852        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18853        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18854        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
18855
18856        let deletes = crate::wal::SharedWal::replay(dir.path())
18857            .unwrap()
18858            .into_iter()
18859            .filter_map(|record| match record.op {
18860                crate::wal::Op::Delete {
18861                    table_id: id,
18862                    row_ids,
18863                } if id == table_id => Some(row_ids.len()),
18864                _ => None,
18865            })
18866            .collect::<Vec<_>>();
18867        assert_eq!(deletes, [2]);
18868    }
18869
18870    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
18871        (0..10_050)
18872            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
18873            .collect()
18874    }
18875
18876    fn test_schema() -> Schema {
18877        Schema {
18878            columns: vec![ColumnDef {
18879                id: 1,
18880                name: "id".into(),
18881                ty: TypeId::Int64,
18882                flags: crate::schema::ColumnFlags::empty()
18883                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
18884                default_value: None,
18885                embedding_source: None,
18886            }],
18887            ..Schema::default()
18888        }
18889    }
18890}
18891
18892#[cfg(test)]
18893mod cdc_bounds_tests {
18894    use super::*;
18895
18896    #[test]
18897    fn retained_byte_limit_rejects_without_allocating_payload() {
18898        let mut retained = 0;
18899        let error = charge_cdc_bytes(
18900            &mut retained,
18901            CDC_MAX_RETAINED_BYTES.saturating_add(1),
18902            "CDC retained bytes",
18903        )
18904        .unwrap_err();
18905        assert!(matches!(
18906            error,
18907            MongrelError::ResourceLimitExceeded {
18908                resource: "CDC retained bytes",
18909                ..
18910            }
18911        ));
18912    }
18913
18914    #[test]
18915    fn row_json_estimate_accounts_for_byte_array_expansion() {
18916        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
18917            .with_column(1, Value::Bytes(vec![0; 1024]));
18918        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
18919    }
18920}
18921
18922#[cfg(test)]
18923mod generation_metrics_tests {
18924    use super::*;
18925    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
18926
18927    #[test]
18928    fn legacy_cow_fallback_is_measured() {
18929        let dir = tempfile::tempdir().unwrap();
18930        let table = Table::create(
18931            dir.path(),
18932            Schema {
18933                columns: vec![ColumnDef {
18934                    id: 1,
18935                    name: "id".into(),
18936                    ty: TypeId::Int64,
18937                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
18938                    default_value: None,
18939                    embedding_source: None,
18940                }],
18941                ..Schema::default()
18942            },
18943            1,
18944        )
18945        .unwrap();
18946        let handle = TableHandle::from_table(table);
18947        let held = match &handle.inner {
18948            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
18949            TableHandleInner::Direct(_) => unreachable!(),
18950        };
18951
18952        handle.lock().set_sync_byte_threshold(1);
18953
18954        let stats = handle.generation_stats();
18955        assert_eq!(stats.cow_clone_count, 1);
18956        assert!(stats.estimated_cow_clone_bytes > 0);
18957        drop(held);
18958    }
18959}
18960
18961#[cfg(test)]
18962mod trigger_engine_tests {
18963    use super::*;
18964
18965    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
18966        WriteEvent {
18967            table: "test".into(),
18968            kind: TriggerEvent::Insert,
18969            new: Some(TriggerRowImage {
18970                columns: new_cells.iter().cloned().collect(),
18971            }),
18972            old: Some(TriggerRowImage {
18973                columns: old_cells.iter().cloned().collect(),
18974            }),
18975            changed_columns: Vec::new(),
18976            op_indices: Vec::new(),
18977            put_idx: None,
18978            trigger_stack: Vec::new(),
18979        }
18980    }
18981
18982    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
18983        WriteEvent {
18984            table: "test".into(),
18985            kind: TriggerEvent::Insert,
18986            new: Some(TriggerRowImage {
18987                columns: new_cells.iter().cloned().collect(),
18988            }),
18989            old: None,
18990            changed_columns: Vec::new(),
18991            op_indices: Vec::new(),
18992            put_idx: None,
18993            trigger_stack: Vec::new(),
18994        }
18995    }
18996
18997    #[test]
18998    fn value_order_int64_vs_float64() {
18999        assert_eq!(
19000            value_order(&Value::Int64(5), &Value::Float64(5.0)),
19001            Some(std::cmp::Ordering::Equal)
19002        );
19003        assert_eq!(
19004            value_order(&Value::Int64(5), &Value::Float64(3.0)),
19005            Some(std::cmp::Ordering::Greater)
19006        );
19007        assert_eq!(
19008            value_order(&Value::Int64(2), &Value::Float64(3.0)),
19009            Some(std::cmp::Ordering::Less)
19010        );
19011    }
19012
19013    #[test]
19014    fn value_order_null_returns_none() {
19015        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
19016        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
19017        assert_eq!(value_order(&Value::Null, &Value::Null), None);
19018    }
19019
19020    #[test]
19021    fn value_order_cross_group_returns_none() {
19022        assert_eq!(
19023            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
19024            None
19025        );
19026        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
19027        assert_eq!(
19028            value_order(
19029                &Value::Embedding(vec![1.0, 2.0]),
19030                &Value::Embedding(vec![1.0, 2.0])
19031            ),
19032            None
19033        );
19034    }
19035
19036    #[test]
19037    fn eval_trigger_expr_ranges_and_booleans() {
19038        let expr = TriggerExpr::And {
19039            left: Box::new(TriggerExpr::Gt {
19040                left: TriggerValue::NewColumn(1),
19041                right: TriggerValue::Literal(Value::Int64(0)),
19042            }),
19043            right: Box::new(TriggerExpr::Lte {
19044                left: TriggerValue::NewColumn(1),
19045                right: TriggerValue::Literal(Value::Int64(100)),
19046            }),
19047        };
19048        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
19049        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
19050        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
19051
19052        let or_expr = TriggerExpr::Or {
19053            left: Box::new(TriggerExpr::Lt {
19054                left: TriggerValue::NewColumn(1),
19055                right: TriggerValue::Literal(Value::Int64(0)),
19056            }),
19057            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
19058                TriggerValue::OldColumn(2),
19059            )))),
19060        };
19061        assert!(eval_trigger_expr(
19062            &or_expr,
19063            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
19064        )
19065        .unwrap());
19066        assert!(!eval_trigger_expr(
19067            &or_expr,
19068            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
19069        )
19070        .unwrap());
19071
19072        assert!(eval_trigger_expr(
19073            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
19074            &event_insert(&[])
19075        )
19076        .unwrap());
19077        assert!(!eval_trigger_expr(
19078            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
19079            &event_insert(&[])
19080        )
19081        .unwrap());
19082        assert!(!eval_trigger_expr(
19083            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
19084            &event_insert(&[])
19085        )
19086        .unwrap());
19087    }
19088}
19089
19090#[cfg(test)]
19091mod core_resource_tests {
19092    use super::*;
19093
19094    fn int_pk_schema() -> Schema {
19095        Schema {
19096            columns: vec![ColumnDef {
19097                id: 1,
19098                name: "id".into(),
19099                ty: TypeId::Int64,
19100                flags: crate::schema::ColumnFlags::empty()
19101                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19102                default_value: None,
19103                embedding_source: None,
19104            }],
19105            ..Schema::default()
19106        }
19107    }
19108
19109    #[test]
19110    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
19111        let dir = tempfile::tempdir().unwrap();
19112        let db = Database::create(dir.path()).unwrap();
19113        assert_eq!(
19114            db.memory_governor().max_bytes(),
19115            DEFAULT_MEMORY_BUDGET_BYTES
19116        );
19117        assert_eq!(
19118            db.spill_manager().config().global_bytes,
19119            DEFAULT_TEMP_DISK_BUDGET_BYTES
19120        );
19121        assert!(db.job_registry().list().is_empty());
19122        // S1E-002: class defaults seeded at open; no external embedding vendor.
19123        assert_eq!(
19124            db.resource_groups().len(),
19125            crate::resource::WorkloadClass::ALL.len()
19126        );
19127        assert!(db.resource_groups().get("control").is_some());
19128        assert!(db.embedding_providers().list_ids().is_empty());
19129        // Application-supplied path refuses generation (no silent hashed vectors).
19130        let err = db
19131            .embedding_providers()
19132            .embed(
19133                &crate::embedding::EmbeddingSource::SuppliedByApplication,
19134                &["text"],
19135                4,
19136            )
19137            .unwrap_err();
19138        assert!(matches!(
19139            err,
19140            crate::embedding::EmbeddingError::SuppliedByApplication
19141        ));
19142    }
19143
19144    #[test]
19145    fn lock_rows_for_update_acquires_exclusive_row_locks() {
19146        use crate::locks::LockKey;
19147        use crate::rowid::RowId;
19148
19149        let dir = tempfile::tempdir().unwrap();
19150        let db = Database::create(dir.path()).unwrap();
19151        let txn_id = db.allocate_lock_txn_id().unwrap();
19152        let rid = RowId(42);
19153        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
19154        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
19155        db.release_txn_locks(txn_id);
19156        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
19157    }
19158
19159    #[test]
19160    fn open_with_options_sizes_the_core_budgets() {
19161        let dir = tempfile::tempdir().unwrap();
19162        let db = Database::create(dir.path()).unwrap();
19163        drop(db);
19164        let db = Database::open_with_options(
19165            dir.path(),
19166            OpenOptions::default()
19167                .with_memory_budget_bytes(256 * 1024 * 1024)
19168                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
19169        )
19170        .unwrap();
19171        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
19172        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
19173    }
19174
19175    #[test]
19176    fn zero_budgets_are_rejected() {
19177        let dir = tempfile::tempdir().unwrap();
19178        let db = Database::create(dir.path()).unwrap();
19179        drop(db);
19180        let result = Database::open_with_options(
19181            dir.path(),
19182            OpenOptions::default().with_memory_budget_bytes(0),
19183        );
19184        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
19185        let result = Database::open_with_options(
19186            dir.path(),
19187            OpenOptions::default().with_temp_disk_budget_bytes(0),
19188        );
19189        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
19190    }
19191
19192    #[test]
19193    fn page_caches_reserve_under_the_governor() {
19194        let dir = tempfile::tempdir().unwrap();
19195        let db = Database::create(dir.path()).unwrap();
19196        db.create_table("t", int_pk_schema()).unwrap();
19197        let mut txn = db.begin();
19198        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
19199        txn.commit().unwrap();
19200        // S1E-003: both caches hold reservations under the core's governor, so
19201        // the governor's per-class accounting mirrors their live bytes, and
19202        // both are registered as reclaimable subsystems it can evict.
19203        let stats = db.memory_governor().stats();
19204        assert_eq!(
19205            stats.usage_for(crate::memory::MemoryClass::PageCache),
19206            db.page_cache.used_bytes()
19207        );
19208        assert_eq!(
19209            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
19210            db.decoded_cache.used_bytes()
19211        );
19212        assert_eq!(
19213            db.memory_governor().reclaimable_bytes(),
19214            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
19215        );
19216        // Driving an eviction through the governor is safe at any level.
19217        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
19218    }
19219
19220    #[test]
19221    fn job_registry_persists_across_reopen() {
19222        let dir = tempfile::tempdir().unwrap();
19223        let db = Database::create(dir.path()).unwrap();
19224        db.create_table("t", int_pk_schema()).unwrap();
19225        let job_id = db
19226            .job_registry()
19227            .submit(
19228                crate::jobs::JobKind::IndexBuild,
19229                crate::jobs::JobTarget {
19230                    table: "t".to_string(),
19231                    index: Some("idx".to_string()),
19232                },
19233            )
19234            .unwrap();
19235        drop(db);
19236        let db = Database::open(dir.path()).unwrap();
19237        let record = db.job_registry().get(job_id).expect("job survives reopen");
19238        assert_eq!(record.state, crate::jobs::JobState::Pending);
19239    }
19240
19241    #[test]
19242    fn spill_manager_open_sweeps_stale_temp_tree() {
19243        let dir = tempfile::tempdir().unwrap();
19244        let db = Database::create(dir.path()).unwrap();
19245        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
19246        std::fs::create_dir_all(&stale).unwrap();
19247        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
19248        drop(db);
19249        let db = Database::open(dir.path()).unwrap();
19250        assert!(
19251            !stale.exists(),
19252            "the startup sweep removes stale spill files (S1E-004)"
19253        );
19254        // A fresh session can start against the swept manager.
19255        let session = db
19256            .spill_manager()
19257            .begin_query(
19258                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
19259                1024 * 1024,
19260            )
19261            .unwrap();
19262        assert_eq!(session.used(), 0);
19263    }
19264}
19265
19266#[cfg(test)]
19267mod version_pin_tests {
19268    use super::*;
19269
19270    fn int_pk_schema() -> Schema {
19271        Schema {
19272            columns: vec![ColumnDef {
19273                id: 1,
19274                name: "id".into(),
19275                ty: TypeId::Int64,
19276                flags: crate::schema::ColumnFlags::empty()
19277                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19278                default_value: None,
19279                embedding_source: None,
19280            }],
19281            ..Schema::default()
19282        }
19283    }
19284
19285    fn pins_for(
19286        report: &[TablePinsReport],
19287        table: &str,
19288        source: crate::retention::PinSource,
19289    ) -> Option<crate::retention::PinInfo> {
19290        report
19291            .iter()
19292            .find(|entry| entry.table == table)
19293            .and_then(|entry| entry.pins.get(source).cloned())
19294    }
19295
19296    #[test]
19297    fn backup_boundary_registers_backup_pitr_pin() {
19298        let source = tempfile::tempdir().unwrap();
19299        let destination_parent = tempfile::tempdir().unwrap();
19300        let destination = destination_parent.path().join("backup");
19301        let db = Arc::new(Database::create(source.path()).unwrap());
19302        db.create_table("t", int_pk_schema()).unwrap();
19303        let mut txn = db.begin();
19304        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
19305        let boundary_epoch = txn.commit().unwrap();
19306
19307        let hold = Arc::new(std::sync::Barrier::new(2));
19308        let resume = Arc::new(std::sync::Barrier::new(2));
19309        db.__set_backup_hook({
19310            let hold = Arc::clone(&hold);
19311            let resume = Arc::clone(&resume);
19312            move || {
19313                hold.wait();
19314                resume.wait();
19315            }
19316        });
19317
19318        let backup = {
19319            let db = Arc::clone(&db);
19320            let destination = destination.clone();
19321            std::thread::spawn(move || db.hot_backup(destination))
19322        };
19323        hold.wait();
19324        // The hook fires while the backup's pins are held: the boundary must
19325        // show up as a BackupPitr pin on the table's unified registry.
19326        let report = db.version_pins_report();
19327        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
19328            .expect("backup boundary must register a BackupPitr pin");
19329        assert_eq!(pin.oldest_epoch, boundary_epoch);
19330        assert!(pin.pin_count >= 1);
19331        resume.wait();
19332        backup.join().unwrap().unwrap();
19333
19334        let report = db.version_pins_report();
19335        assert!(
19336            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
19337            "the BackupPitr pin releases when the backup finishes"
19338        );
19339    }
19340
19341    #[test]
19342    fn snapshot_and_read_generation_pins_surface_in_report() {
19343        let dir = tempfile::tempdir().unwrap();
19344        let db = Database::create(dir.path()).unwrap();
19345        db.create_table("t", int_pk_schema()).unwrap();
19346        let mut txn = db.begin();
19347        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
19348        txn.commit().unwrap();
19349
19350        let (_snapshot, guard) = db.snapshot();
19351        let report = db.version_pins_report();
19352        assert!(
19353            pins_for(
19354                &report,
19355                "t",
19356                crate::retention::PinSource::TransactionSnapshot
19357            )
19358            .is_some(),
19359            "a database snapshot projects the TransactionSnapshot source"
19360        );
19361        drop(guard);
19362
19363        let handle = db.table("t").unwrap();
19364        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
19365        let report = db.version_pins_report();
19366        assert!(
19367            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
19368            "a cloned read generation registers a ReadGeneration pin"
19369        );
19370        drop(generation);
19371
19372        let report = db.version_pins_report();
19373        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
19374        assert!(
19375            entry
19376                .pins
19377                .get(crate::retention::PinSource::BackupPitr)
19378                .is_none()
19379                && entry
19380                    .pins
19381                    .get(crate::retention::PinSource::Replication)
19382                    .is_none()
19383                && entry
19384                    .pins
19385                    .get(crate::retention::PinSource::OnlineIndexBuild)
19386                    .is_none(),
19387            "untaken sources stay absent from the report"
19388        );
19389    }
19390}
19391
19392#[cfg(test)]
19393mod lock_manager_tests {
19394    use super::*;
19395    use crate::locks::LockKey;
19396
19397    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
19398        ColumnDef {
19399            id,
19400            name: name.into(),
19401            ty,
19402            flags,
19403            default_value: None,
19404            embedding_source: None,
19405        }
19406    }
19407
19408    fn unique_schema() -> Schema {
19409        let mut constraints = crate::constraint::TableConstraints::default();
19410        constraints
19411            .uniques
19412            .push(crate::constraint::UniqueConstraint {
19413                id: 1,
19414                name: "users_email_unique".into(),
19415                columns: vec![1],
19416            });
19417        Schema {
19418            columns: vec![
19419                col(
19420                    0,
19421                    "id",
19422                    TypeId::Int64,
19423                    crate::schema::ColumnFlags::empty()
19424                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19425                ),
19426                col(
19427                    1,
19428                    "email",
19429                    TypeId::Bytes,
19430                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
19431                ),
19432            ],
19433            constraints,
19434            ..Schema::default()
19435        }
19436    }
19437
19438    fn parent_schema() -> Schema {
19439        Schema {
19440            columns: vec![col(
19441                0,
19442                "id",
19443                TypeId::Int64,
19444                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
19445            )],
19446            ..Schema::default()
19447        }
19448    }
19449
19450    fn child_schema() -> Schema {
19451        let mut constraints = crate::constraint::TableConstraints::default();
19452        constraints
19453            .foreign_keys
19454            .push(crate::constraint::ForeignKey {
19455                id: 1,
19456                name: "child_parent_fk".into(),
19457                columns: vec![1],
19458                ref_table: "parent".into(),
19459                ref_columns: vec![0],
19460                on_delete: crate::constraint::FkAction::Restrict,
19461                on_update: crate::constraint::FkAction::Restrict,
19462            });
19463        Schema {
19464            columns: vec![
19465                col(
19466                    0,
19467                    "id",
19468                    TypeId::Int64,
19469                    crate::schema::ColumnFlags::empty()
19470                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19471                ),
19472                col(
19473                    1,
19474                    "pid",
19475                    TypeId::Int64,
19476                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
19477                ),
19478            ],
19479            constraints,
19480            ..Schema::default()
19481        }
19482    }
19483
19484    fn auto_inc_schema() -> Schema {
19485        Schema {
19486            columns: vec![col(
19487                0,
19488                "id",
19489                TypeId::Int64,
19490                crate::schema::ColumnFlags::empty()
19491                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
19492                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
19493            )],
19494            ..Schema::default()
19495        }
19496    }
19497
19498    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
19499        let mut key = b"pk:".to_vec();
19500        key.extend_from_slice(&Value::Int64(value).encode_key());
19501        LockKey::key(table_id, key)
19502    }
19503
19504    #[test]
19505    fn unique_claims_serialize_concurrent_commits() {
19506        let dir = tempfile::tempdir().unwrap();
19507        let db = Arc::new(Database::create(dir.path()).unwrap());
19508        let table_id = db.create_table("users", unique_schema()).unwrap();
19509        let pk_key = pk_lock_key(table_id, 1);
19510        let entered = Arc::new(std::sync::Barrier::new(2));
19511        let resume = Arc::new(std::sync::Barrier::new(2));
19512        let parked = Arc::new(AtomicBool::new(false));
19513        db.__set_catalog_commit_hook({
19514            let entered = Arc::clone(&entered);
19515            let resume = Arc::clone(&resume);
19516            let parked = Arc::clone(&parked);
19517            move || {
19518                // Park only the first commit to reach the sequencer; later
19519                // commits pass straight through.
19520                if !parked.swap(true, Ordering::SeqCst) {
19521                    entered.wait();
19522                    resume.wait();
19523                }
19524            }
19525        });
19526
19527        let mut txn_a = db.begin();
19528        txn_a
19529            .put(
19530                "users",
19531                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
19532            )
19533            .unwrap();
19534        let a_id = txn_a.txn_id();
19535        let (a_tx, a_rx) = std::sync::mpsc::channel();
19536        let (b_tx, b_rx) = std::sync::mpsc::channel();
19537        std::thread::scope(|scope| {
19538            scope.spawn(|| {
19539                a_tx.send(txn_a.commit()).unwrap();
19540            });
19541            entered.wait();
19542            // A is parked in the sequencer holding its unique claims.
19543            assert!(
19544                db.lock_manager().holds(a_id, &pk_key),
19545                "primary-key claim must be held until the commit ends"
19546            );
19547            let mut uq_key = format!("uq{}:", 1).into_bytes();
19548            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
19549                .into_iter()
19550                .collect();
19551            uq_key.extend_from_slice(
19552                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
19553            );
19554            assert!(
19555                db.lock_manager()
19556                    .holds(a_id, &LockKey::key(table_id, uq_key)),
19557                "declared-unique claim must be held until the commit ends"
19558            );
19559
19560            let mut txn_b = db.begin();
19561            txn_b
19562                .put(
19563                    "users",
19564                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
19565                )
19566                .unwrap();
19567            scope.spawn(|| {
19568                b_tx.send(txn_b.commit()).unwrap();
19569            });
19570            std::thread::sleep(std::time::Duration::from_millis(100));
19571            assert!(
19572                b_rx.try_recv().is_err(),
19573                "the concurrent claim must block until A ends its transaction"
19574            );
19575            resume.wait();
19576            assert!(a_rx.recv().unwrap().is_ok());
19577            let b_result = b_rx.recv().unwrap();
19578            assert!(
19579                matches!(b_result, Err(MongrelError::Conflict(_))),
19580                "the loser surfaces a conflict after serializing: {b_result:?}"
19581            );
19582        });
19583        assert!(
19584            !db.lock_manager().holds(a_id, &pk_key),
19585            "no phantom holds remain after the commit"
19586        );
19587    }
19588
19589    #[test]
19590    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
19591        let dir = tempfile::tempdir().unwrap();
19592        let db = Arc::new(Database::create(dir.path()).unwrap());
19593        db.create_table("parent", parent_schema()).unwrap();
19594        db.create_table("child", child_schema()).unwrap();
19595        let mut seed = db.begin();
19596        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
19597        seed.commit().unwrap();
19598
19599        let entered = Arc::new(std::sync::Barrier::new(2));
19600        let resume = Arc::new(std::sync::Barrier::new(2));
19601        db.__set_fk_lock_hook({
19602            let entered = Arc::clone(&entered);
19603            let resume = Arc::clone(&resume);
19604            move || {
19605                entered.wait();
19606                resume.wait();
19607            }
19608        });
19609
19610        let mut txn_a = db.begin();
19611        txn_a
19612            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
19613            .unwrap();
19614        let a_id = txn_a.txn_id();
19615        let (a_tx, a_rx) = std::sync::mpsc::channel();
19616        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
19617        std::thread::scope(|scope| {
19618            scope.spawn(|| {
19619                a_tx.send(txn_a.commit()).unwrap();
19620            });
19621            entered.wait();
19622            // A is parked mid-validation: schema barrier held Shared.
19623            assert!(
19624                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
19625                "DML holds the schema barrier Shared for its commit"
19626            );
19627            let db = Arc::clone(&db);
19628            scope.spawn(move || {
19629                ddl_tx.send(db.drop_table("parent")).unwrap();
19630            });
19631            std::thread::sleep(std::time::Duration::from_millis(100));
19632            assert!(
19633                ddl_rx.try_recv().is_err(),
19634                "DDL must wait on the Exclusive schema barrier while DML is in flight"
19635            );
19636            resume.wait();
19637            // A now finishes and the waiting DDL proceeds. A's commit may
19638            // legitimately lose the publish race against the DDL's security
19639            // version advance — the designed "security policy changed during
19640            // write" outcome — or win it; the barrier guarantees only that the
19641            // DDL could not proceed before A released it.
19642            let a_result = a_rx.recv().unwrap();
19643            match &a_result {
19644                Ok(_) => {}
19645                Err(MongrelError::Conflict(message)) => {
19646                    assert!(
19647                        message.contains("security policy changed during write"),
19648                        "unexpected commit conflict: {message}"
19649                    );
19650                }
19651                other => panic!("unexpected commit outcome: {other:?}"),
19652            }
19653            assert!(ddl_rx.recv().unwrap().is_ok());
19654        });
19655        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
19656    }
19657
19658    #[test]
19659    fn auto_increment_sequence_barrier_held_until_commit() {
19660        let dir = tempfile::tempdir().unwrap();
19661        let db = Database::create(dir.path()).unwrap();
19662        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
19663        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
19664        let entered = Arc::new(std::sync::Barrier::new(2));
19665        let resume = Arc::new(std::sync::Barrier::new(2));
19666        let parked = Arc::new(AtomicBool::new(false));
19667        db.__set_catalog_commit_hook({
19668            let entered = Arc::clone(&entered);
19669            let resume = Arc::clone(&resume);
19670            let parked = Arc::clone(&parked);
19671            move || {
19672                if !parked.swap(true, Ordering::SeqCst) {
19673                    entered.wait();
19674                    resume.wait();
19675                }
19676            }
19677        });
19678
19679        let mut txn_a = db.begin();
19680        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
19681        let a_id = txn_a.txn_id();
19682        // The stage-time allocation already holds the barrier.
19683        assert!(
19684            db.lock_manager().holds(a_id, &barrier_key),
19685            "sequence allocation takes the barrier at stage time"
19686        );
19687        let (a_tx, a_rx) = std::sync::mpsc::channel();
19688        std::thread::scope(|scope| {
19689            scope.spawn(|| {
19690                a_tx.send(txn_a.commit()).unwrap();
19691            });
19692            entered.wait();
19693            assert!(
19694                db.lock_manager().holds(a_id, &barrier_key),
19695                "the barrier is held through the commit"
19696            );
19697            resume.wait();
19698            assert!(a_rx.recv().unwrap().is_ok());
19699        });
19700        assert!(
19701            !db.lock_manager().holds(a_id, &barrier_key),
19702            "the barrier releases when the commit ends"
19703        );
19704    }
19705
19706    #[test]
19707    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
19708        let dir = tempfile::tempdir().unwrap();
19709        let db = Database::create(dir.path()).unwrap();
19710        db.create_table("parent", parent_schema()).unwrap();
19711        db.create_table("child", child_schema()).unwrap();
19712        let mut seed = db.begin();
19713        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
19714        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
19715        seed.commit().unwrap();
19716        let (rid1, rid2) = {
19717            let handle = db.table("parent").unwrap();
19718            let table = handle.lock();
19719            let rid = |pk: i64| {
19720                table
19721                    .lookup_pk(&Value::Int64(pk).encode_key())
19722                    .expect("seeded parent row")
19723            };
19724            (rid(1), rid(2))
19725        };
19726
19727        // The hook fires after every successful FK lock acquisition; park the
19728        // first two (A's and B's Exclusive delete-side claims) so both are
19729        // held before either transaction attempts its Shared insert-side
19730        // claim — a deterministic wait-for cycle.
19731        let rendezvous = Arc::new(std::sync::Barrier::new(2));
19732        let calls = Arc::new(AtomicUsize::new(0));
19733        db.__set_fk_lock_hook({
19734            let rendezvous = Arc::clone(&rendezvous);
19735            let calls = Arc::clone(&calls);
19736            move || {
19737                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
19738                    rendezvous.wait();
19739                }
19740            }
19741        });
19742
19743        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
19744        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
19745        let mut txn_a = db.begin();
19746        txn_a.delete("parent", rid1).unwrap();
19747        txn_a
19748            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
19749            .unwrap();
19750        let mut txn_b = db.begin();
19751        txn_b.delete("parent", rid2).unwrap();
19752        txn_b
19753            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
19754            .unwrap();
19755        let b_id = txn_b.txn_id();
19756
19757        let (a_tx, a_rx) = std::sync::mpsc::channel();
19758        let (b_tx, b_rx) = std::sync::mpsc::channel();
19759        std::thread::scope(|scope| {
19760            scope.spawn(|| {
19761                a_tx.send(txn_a.commit()).unwrap();
19762            });
19763            scope.spawn(|| {
19764                b_tx.send(txn_b.commit()).unwrap();
19765            });
19766            let a_result = a_rx.recv().unwrap();
19767            let b_result = b_rx.recv().unwrap();
19768            assert!(
19769                a_result.is_ok(),
19770                "the survivor commits once the victim releases: {a_result:?}"
19771            );
19772            match b_result {
19773                Err(MongrelError::Deadlock { victim, .. }) => {
19774                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
19775                }
19776                other => panic!("the victim must surface a deadlock, got {other:?}"),
19777            }
19778        });
19779        // No phantom holds survive the victim's release.
19780        let fk_key = |table: &str, pk: i64| {
19781            let table_id = db.table_id(table).unwrap();
19782            let mut key = b"fk:".to_vec();
19783            key.extend_from_slice(&Value::Int64(pk).encode_key());
19784            LockKey::key(table_id, key)
19785        };
19786        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
19787        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
19788    }
19789
19790    #[test]
19791    fn locks_release_after_commit_rollback_and_failed_commit() {
19792        let dir = tempfile::tempdir().unwrap();
19793        let db = Database::create(dir.path()).unwrap();
19794        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
19795        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
19796
19797        // Successful commit: the stage-time barrier releases with the commit.
19798        let mut txn = db.begin();
19799        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
19800        let committed_id = txn.txn_id();
19801        txn.commit().unwrap();
19802        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
19803
19804        // Rollback: the stage-time barrier releases with the drop.
19805        let mut txn = db.begin();
19806        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
19807        let rolled_back_id = txn.txn_id();
19808        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
19809        txn.rollback();
19810        assert!(
19811            !db.lock_manager().holds(rolled_back_id, &barrier_key),
19812            "rollback must not leave phantom holds"
19813        );
19814
19815        // Failed commit (declared-unique violation): the claims release with
19816        // the error.
19817        db.create_table("users", unique_schema()).unwrap();
19818        let users_id = db.table_id("users").unwrap();
19819        let mut seed = db.begin();
19820        seed.put(
19821            "users",
19822            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
19823        )
19824        .unwrap();
19825        seed.commit().unwrap();
19826        let mut txn = db.begin();
19827        // A different primary key but the same declared-unique email: the
19828        // Phase B unique check rejects this commit.
19829        txn.put(
19830            "users",
19831            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
19832        )
19833        .unwrap();
19834        let failed_id = txn.txn_id();
19835        let result = txn.commit();
19836        assert!(matches!(result, Err(MongrelError::Conflict(_))));
19837        assert!(
19838            !db.lock_manager()
19839                .holds(failed_id, &pk_lock_key(users_id, 2)),
19840            "a failed commit must not leave phantom holds"
19841        );
19842    }
19843}
19844
19845#[cfg(test)]
19846mod lifecycle_tests {
19847    use super::*;
19848
19849    fn int_pk_schema() -> Schema {
19850        Schema {
19851            columns: vec![ColumnDef {
19852                id: 1,
19853                name: "id".into(),
19854                ty: TypeId::Int64,
19855                flags: crate::schema::ColumnFlags::empty()
19856                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19857                default_value: None,
19858                embedding_source: None,
19859            }],
19860            ..Schema::default()
19861        }
19862    }
19863
19864    #[test]
19865    fn poisoned_core_rejects_operations_with_typed_errors() {
19866        let dir = tempfile::tempdir().unwrap();
19867        let db = Database::create(dir.path()).unwrap();
19868        db.create_table("t", int_pk_schema()).unwrap();
19869        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
19870
19871        // Drive the exact two-state poison the fsync-error sites set
19872        // (write-path flag + lifecycle transition), without process-global
19873        // fault injection, which would leak into parallel tests. The fsync
19874        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
19875        db.poisoned.store(true, Ordering::Relaxed);
19876        db.lifecycle.poison();
19877        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
19878
19879        // Guarded operations without their own write-path poison check reject
19880        // at admission with the lifecycle Conflict...
19881        let error = db.gc().unwrap_err();
19882        assert!(
19883            matches!(error, MongrelError::Conflict(_)),
19884            "gc must reject on a poisoned core: {error:?}"
19885        );
19886        let error = db.compact().unwrap_err();
19887        assert!(
19888            matches!(error, MongrelError::Conflict(_)),
19889            "compact must reject on a poisoned core: {error:?}"
19890        );
19891        assert!(db.operation_guard().is_err());
19892        // ...while paths that already checked the write-path flag keep their
19893        // legacy error.
19894        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
19895        assert!(
19896            error.to_string().contains("database poisoned"),
19897            "the legacy poison error still wins where it existed: {error:?}"
19898        );
19899        let mut txn = db.begin();
19900        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
19901        assert!(txn
19902            .commit()
19903            .unwrap_err()
19904            .to_string()
19905            .contains("database poisoned"));
19906    }
19907
19908    #[test]
19909    fn shutdown_waits_for_operation_guards_to_drain() {
19910        let dir = tempfile::tempdir().unwrap();
19911        let db = Arc::new(Database::create(dir.path()).unwrap());
19912        db.create_table("t", int_pk_schema()).unwrap();
19913        // The guard holds the lifecycle's Arc, not the database's, so the
19914        // exclusive-owner shutdown can proceed to its drain step below.
19915        let guard = db.operation_guard().unwrap();
19916        let (started_tx, started_rx) = std::sync::mpsc::channel();
19917        let (done_tx, done_rx) = std::sync::mpsc::channel();
19918        let shutdown_thread = std::thread::spawn(move || {
19919            started_tx.send(()).unwrap();
19920            let result = db.shutdown();
19921            let _ = done_tx.send(result);
19922        });
19923        started_rx.recv().unwrap();
19924        std::thread::sleep(std::time::Duration::from_millis(100));
19925        assert!(
19926            done_rx.try_recv().is_err(),
19927            "shutdown must wait for the outstanding guard to drain"
19928        );
19929        drop(guard);
19930        shutdown_thread.join().unwrap();
19931        assert!(
19932            done_rx.recv().unwrap().is_ok(),
19933            "shutdown completes once the guard drops"
19934        );
19935    }
19936}
19937
19938#[cfg(test)]
19939mod commit_ts_ledger_tests {
19940    use super::*;
19941
19942    fn int_pk_schema() -> Schema {
19943        Schema {
19944            columns: vec![ColumnDef {
19945                id: 1,
19946                name: "id".into(),
19947                ty: TypeId::Int64,
19948                flags: crate::schema::ColumnFlags::empty()
19949                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19950                default_value: None,
19951                embedding_source: None,
19952            }],
19953            ..Schema::default()
19954        }
19955    }
19956
19957    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
19958        let mut txn = db.begin();
19959        let handle = txn.state_handle();
19960        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
19961        let epoch = txn.commit().unwrap();
19962        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
19963            panic!("expected Committed, got {:?}", handle.state());
19964        };
19965        (epoch, receipt.commit_ts)
19966    }
19967
19968    #[test]
19969    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
19970        let dir = tempfile::tempdir().unwrap();
19971        let db = Database::create(dir.path()).unwrap();
19972        db.create_table("t", int_pk_schema()).unwrap();
19973
19974        let (epoch, commit_ts) = commit_one(&db);
19975        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
19976        // An epoch no commit sealed misses (callers fall back).
19977        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
19978    }
19979
19980    #[test]
19981    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
19982        let dir = tempfile::tempdir().unwrap();
19983        let (epoch, commit_ts) = {
19984            let db = Database::create(dir.path()).unwrap();
19985            db.create_table("t", int_pk_schema()).unwrap();
19986            commit_one(&db)
19987        };
19988
19989        let db = Database::open(dir.path()).unwrap();
19990        let reconstructed = db
19991            .commit_ts_for_epoch(epoch)
19992            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
19993        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
19994        // The ledger byte format stores micros only (spec §8.1): the logical
19995        // counter and tiebreaker reconstruct as 0.
19996        assert_eq!(reconstructed.logical, 0);
19997        assert_eq!(reconstructed.node_tiebreaker, 0);
19998    }
19999
20000    #[test]
20001    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
20002        use crate::wal::Op;
20003        let records = vec![
20004            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
20005            crate::wal::Record::new(
20006                Epoch(2),
20007                7,
20008                Op::TxnCommit {
20009                    epoch: 41,
20010                    added_runs: vec![],
20011                },
20012            ),
20013            // No CommitTimestamp for txn 8: not reconstructible.
20014            crate::wal::Record::new(
20015                Epoch(3),
20016                8,
20017                Op::TxnCommit {
20018                    epoch: 42,
20019                    added_runs: vec![],
20020                },
20021            ),
20022            // Timestamp without a commit marker: aborted, not reconstructible.
20023            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
20024        ];
20025        let ledger = commit_ts_ledger_from_recovery(&records);
20026        assert_eq!(ledger.len(), 1);
20027        assert_eq!(
20028            ledger.get(&41),
20029            Some(&mongreldb_types::hlc::HlcTimestamp {
20030                physical_micros: 1,
20031                logical: 0,
20032                node_tiebreaker: 0,
20033            })
20034        );
20035    }
20036}
20037
20038#[cfg(test)]
20039mod stage2e_storage_mode_tests {
20040    use super::*;
20041    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20042    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
20043    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
20044
20045    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
20046        (
20047            ClusterId::from_bytes([seed; 16]),
20048            NodeId::from_bytes([seed + 1; 16]),
20049            DatabaseId::from_bytes([seed + 2; 16]),
20050        )
20051    }
20052
20053    fn marker(root: &Path) -> Option<StorageMode> {
20054        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
20055        crate::storage_mode::read(&durable).unwrap()
20056    }
20057
20058    fn simple_schema() -> Schema {
20059        Schema {
20060            columns: vec![ColumnDef {
20061                id: 1,
20062                name: "id".into(),
20063                ty: TypeId::Int64,
20064                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20065                default_value: None,
20066                embedding_source: None,
20067            }],
20068            ..Schema::default()
20069        }
20070    }
20071
20072    #[test]
20073    fn standalone_create_writes_marker_and_reopens() {
20074        let dir = tempfile::tempdir().unwrap();
20075        let root = dir.path().join("db");
20076        let db = Database::create(&root).unwrap();
20077        assert_eq!(marker(&root), Some(StorageMode::Standalone));
20078        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
20079        drop(db);
20080        let db = Database::open(&root).unwrap();
20081        assert_eq!(marker(&root), Some(StorageMode::Standalone));
20082        drop(db);
20083    }
20084
20085    #[test]
20086    fn legacy_database_without_marker_opens_and_gains_marker() {
20087        let dir = tempfile::tempdir().unwrap();
20088        let root = dir.path().join("db");
20089        let db = Database::create(&root).unwrap();
20090        drop(db);
20091        // Simulate a pre-marker database.
20092        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
20093        assert_eq!(marker(&root), None);
20094        let db = Database::open(&root).unwrap();
20095        assert_eq!(marker(&root), Some(StorageMode::Standalone));
20096        drop(db);
20097    }
20098
20099    #[test]
20100    fn server_owned_standalone_opens_embedded() {
20101        let dir = tempfile::tempdir().unwrap();
20102        let root = dir.path().join("db");
20103        let db = Database::create(&root).unwrap();
20104        drop(db);
20105        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
20106        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
20107        let db = Database::open(&root).unwrap();
20108        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
20109        drop(db);
20110    }
20111
20112    #[test]
20113    fn cluster_replica_is_rejected_by_normal_opens() {
20114        let dir = tempfile::tempdir().unwrap();
20115        let root = dir.path().join("db");
20116        let (cluster_id, node_id, database_id) = identity(10);
20117        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
20118        assert_eq!(
20119            marker(&root),
20120            Some(StorageMode::ClusterReplica {
20121                cluster_id,
20122                node_id,
20123                database_id,
20124            })
20125        );
20126        drop(db);
20127
20128        let error = Database::open(&root).unwrap_err();
20129        let message = error.to_string();
20130        assert!(
20131            matches!(error, MongrelError::InvalidArgument(_)),
20132            "unexpected error: {message}"
20133        );
20134        assert!(message.contains("cluster node runtime"), "{message}");
20135        assert!(message.contains(&cluster_id.to_hex()), "{message}");
20136        assert!(message.contains(&node_id.to_hex()), "{message}");
20137        assert!(message.contains(&database_id.to_hex()), "{message}");
20138
20139        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
20140        assert!(error.to_string().contains("cluster node runtime"));
20141        // The rejected opens never disturbed the marker.
20142        assert_eq!(
20143            marker(&root),
20144            Some(StorageMode::ClusterReplica {
20145                cluster_id,
20146                node_id,
20147                database_id,
20148            })
20149        );
20150    }
20151
20152    #[test]
20153    fn offline_validation_opens_cluster_replica_read_only() {
20154        let dir = tempfile::tempdir().unwrap();
20155        let root = dir.path().join("db");
20156        let (cluster_id, node_id, database_id) = identity(20);
20157        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
20158        drop(db);
20159
20160        let options = OpenOptions::default().with_offline_validation(true);
20161        let db = Database::open_with_options(&root, options).unwrap();
20162        assert!(db.is_read_only_replica());
20163        let error = db.create_table("t", simple_schema()).unwrap_err();
20164        assert!(matches!(error, MongrelError::ReadOnlyReplica));
20165        drop(db);
20166        // Offline validation leaves the marker exactly as found.
20167        assert_eq!(
20168            marker(&root),
20169            Some(StorageMode::ClusterReplica {
20170                cluster_id,
20171                node_id,
20172                database_id,
20173            })
20174        );
20175    }
20176
20177    #[test]
20178    fn cluster_runtime_open_requires_exact_identity() {
20179        let dir = tempfile::tempdir().unwrap();
20180        let root = dir.path().join("db");
20181        let (cluster_id, node_id, database_id) = identity(30);
20182        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
20183        drop(db);
20184
20185        // A non-ClusterReplica expectation is a caller error.
20186        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
20187        assert!(matches!(error, MongrelError::InvalidArgument(_)));
20188        // Wrong database identity fails closed.
20189        let wrong = StorageMode::ClusterReplica {
20190            cluster_id,
20191            node_id,
20192            database_id: DatabaseId::from_bytes([99; 16]),
20193        };
20194        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
20195        assert!(error.to_string().contains("identity mismatch"), "{error}");
20196        // A legacy database without a marker is not a cluster replica.
20197        let legacy = dir.path().join("legacy");
20198        let legacy_db = Database::create(&legacy).unwrap();
20199        drop(legacy_db);
20200        let expected = StorageMode::ClusterReplica {
20201            cluster_id,
20202            node_id,
20203            database_id,
20204        };
20205        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
20206        assert!(error.to_string().contains("identity mismatch"), "{error}");
20207
20208        // The matching identity opens; user writes are rejected (writes
20209        // arrive through the replicated apply path only).
20210        let db = Database::open_cluster_replica(&root, &expected).unwrap();
20211        assert!(db.is_read_only_replica());
20212        let error = db.create_table("t", simple_schema()).unwrap_err();
20213        assert!(matches!(error, MongrelError::ReadOnlyReplica));
20214        drop(db);
20215    }
20216}
20217
20218#[cfg(test)]
20219mod stage2e_replicated_apply_tests {
20220    use super::*;
20221    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
20222    use crate::memtable::{Row, Value};
20223    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20224    use crate::wal::{Op, Record};
20225    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
20226    use std::sync::Arc;
20227
20228    fn ids() -> (ClusterId, NodeId, DatabaseId) {
20229        (
20230            ClusterId::from_bytes([1; 16]),
20231            NodeId::from_bytes([2; 16]),
20232            DatabaseId::from_bytes([3; 16]),
20233        )
20234    }
20235
20236    fn expected_mode() -> crate::storage_mode::StorageMode {
20237        let (cluster_id, node_id, database_id) = ids();
20238        crate::storage_mode::StorageMode::ClusterReplica {
20239            cluster_id,
20240            node_id,
20241            database_id,
20242        }
20243    }
20244
20245    fn simple_schema() -> Schema {
20246        Schema {
20247            columns: vec![ColumnDef {
20248                id: 1,
20249                name: "id".into(),
20250                ty: TypeId::Int64,
20251                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20252                default_value: None,
20253                embedding_source: None,
20254            }],
20255            ..Schema::default()
20256        }
20257    }
20258
20259    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
20260        CatalogCommandRecord {
20261            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
20262            catalog_version,
20263            command: CatalogCommand::CreateTable {
20264                name: name.to_string(),
20265                schema: simple_schema(),
20266                created_epoch: 1,
20267            },
20268        }
20269    }
20270
20271    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
20272        let rows: Vec<Row> = values
20273            .iter()
20274            .map(|value| {
20275                // Distinct row ids per value so batches never overwrite each
20276                // other's MVCC versions.
20277                Row::new(crate::RowId(*value as u64), Epoch(epoch))
20278                    .with_column(1, Value::Int64(*value))
20279            })
20280            .collect();
20281        vec![
20282            Record::new(
20283                Epoch(0),
20284                txn_id,
20285                Op::Put {
20286                    table_id,
20287                    rows: bincode::serialize(&rows).unwrap(),
20288                },
20289            ),
20290            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
20291            Record::new(
20292                Epoch(0),
20293                txn_id,
20294                Op::TxnCommit {
20295                    epoch,
20296                    added_runs: Vec::new(),
20297                },
20298            ),
20299        ]
20300    }
20301
20302    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
20303        let handle = db.table(table).unwrap();
20304        let rows = handle
20305            .lock()
20306            .visible_rows(crate::epoch::Snapshot::at(Epoch(u64::MAX)))
20307            .unwrap();
20308        let mut values: Vec<i64> = rows
20309            .iter()
20310            .map(|row| match row.columns.get(&1) {
20311                Some(Value::Int64(value)) => *value,
20312                other => panic!("unexpected column: {other:?}"),
20313            })
20314            .collect();
20315        values.sort_unstable();
20316        values
20317    }
20318
20319    #[test]
20320    fn catalog_command_mounts_table_and_replays_as_noop() {
20321        let dir = tempfile::tempdir().unwrap();
20322        let (cluster_id, node_id, database_id) = ids();
20323        let db =
20324            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20325
20326        let record = create_table_record("items", 1);
20327        let delta = db.apply_replicated_catalog_command(&record).unwrap();
20328        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
20329        assert_eq!(db.table_names(), vec!["items".to_string()]);
20330        assert_eq!(db.catalog_version(), 1);
20331
20332        // Idempotent replay of the same record.
20333        let delta = db.apply_replicated_catalog_command(&record).unwrap();
20334        assert!(matches!(delta, CatalogDelta::NoOp));
20335        assert_eq!(db.table_names().len(), 1);
20336        drop(db);
20337
20338        // The command was checkpointed: the table survives reopen.
20339        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
20340        assert_eq!(db.table_names(), vec!["items".to_string()]);
20341        assert_eq!(db.catalog_version(), 1);
20342    }
20343
20344    #[test]
20345    fn records_apply_rows_and_skip_replays_across_restart() {
20346        let dir = tempfile::tempdir().unwrap();
20347        let (cluster_id, node_id, database_id) = ids();
20348        let db =
20349            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20350        db.apply_replicated_catalog_command(&create_table_record("items", 1))
20351            .unwrap();
20352
20353        let records = put_records(1, 0, 2, &[10, 20, 30]);
20354        assert!(db.apply_replicated_records(&records).unwrap());
20355        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
20356        assert_eq!(db.visible_epoch(), Epoch(2));
20357
20358        // Crash-window redelivery of the same committed transaction is a
20359        // side-effect-free replay.
20360        assert!(!db.apply_replicated_records(&records).unwrap());
20361        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
20362
20363        // A later transaction at a higher epoch still applies.
20364        let later = put_records(2, 0, 3, &[40]);
20365        assert!(db.apply_replicated_records(&later).unwrap());
20366        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
20367        let db = Arc::new(db);
20368        db.shutdown().unwrap();
20369
20370        // Restart: the local WAL replays the applied rows, and the state
20371        // machine's redelivery is recognized as a replay — no double-apply.
20372        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
20373        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
20374        assert!(!db.apply_replicated_records(&later).unwrap());
20375        assert!(!db.apply_replicated_records(&records).unwrap());
20376        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
20377    }
20378
20379    #[test]
20380    fn spilled_run_commits_fail_closed_this_wave() {
20381        let dir = tempfile::tempdir().unwrap();
20382        let (cluster_id, node_id, database_id) = ids();
20383        let db =
20384            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20385        db.apply_replicated_catalog_command(&create_table_record("items", 1))
20386            .unwrap();
20387        let mut records = put_records(1, 0, 2, &[10]);
20388        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20389            panic!("put_records ends in TxnCommit");
20390        };
20391        added_runs.push(crate::wal::AddedRun {
20392            table_id: 0,
20393            run_id: 7,
20394            row_count: 1,
20395            level: 0,
20396            min_row_id: 1,
20397            max_row_id: 1,
20398            content_hash: [0; 32],
20399        });
20400        let error = db.apply_replicated_records(&records).unwrap_err();
20401        assert!(
20402            error.to_string().contains("spilled-run"),
20403            "unexpected error: {error}"
20404        );
20405        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
20406    }
20407
20408    #[test]
20409    fn records_without_commit_marker_fail_closed() {
20410        let dir = tempfile::tempdir().unwrap();
20411        let (cluster_id, node_id, database_id) = ids();
20412        let db =
20413            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20414        db.apply_replicated_catalog_command(&create_table_record("items", 1))
20415            .unwrap();
20416        let mut records = put_records(1, 0, 2, &[10]);
20417        records.pop(); // strip the TxnCommit
20418        let error = db.apply_replicated_records(&records).unwrap_err();
20419        assert!(matches!(error, MongrelError::InvalidArgument(_)));
20420        assert!(db.apply_replicated_records(&[]).is_err());
20421        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
20422    }
20423}
20424
20425#[cfg(test)]
20426mod stage2c_spill_translation_tests {
20427    use super::*;
20428    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
20429    use crate::memtable::{Row, Value};
20430    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20431    use crate::wal::{Op, Record};
20432    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
20433
20434    fn simple_schema() -> Schema {
20435        Schema {
20436            columns: vec![ColumnDef {
20437                id: 1,
20438                name: "id".into(),
20439                ty: TypeId::Int64,
20440                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20441                default_value: None,
20442                embedding_source: None,
20443            }],
20444            ..Schema::default()
20445        }
20446    }
20447
20448    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
20449        CatalogCommandRecord {
20450            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
20451            catalog_version,
20452            command: CatalogCommand::CreateTable {
20453                name: name.to_string(),
20454                schema: simple_schema(),
20455                created_epoch: 1,
20456            },
20457        }
20458    }
20459
20460    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
20461        let handle = db.table(table).unwrap();
20462        let rows = handle
20463            .lock()
20464            .visible_rows(crate::epoch::Snapshot::at(Epoch(u64::MAX)))
20465            .unwrap();
20466        let mut values: Vec<i64> = rows
20467            .iter()
20468            .map(|row| match row.columns.get(&1) {
20469                Some(Value::Int64(value)) => *value,
20470                other => panic!("unexpected column: {other:?}"),
20471            })
20472            .collect();
20473        values.sort_unstable();
20474        values
20475    }
20476
20477    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
20478        let rows: Vec<Row> = values
20479            .iter()
20480            .map(|value| {
20481                Row::new(crate::RowId(*value as u64), Epoch(epoch))
20482                    .with_column(1, Value::Int64(*value))
20483            })
20484            .collect();
20485        vec![
20486            Record::new(
20487                Epoch(0),
20488                txn_id,
20489                Op::Put {
20490                    table_id,
20491                    rows: bincode::serialize(&rows).unwrap(),
20492                },
20493            ),
20494            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
20495            Record::new(
20496                Epoch(0),
20497                txn_id,
20498                Op::TxnCommit {
20499                    epoch,
20500                    added_runs: Vec::new(),
20501                },
20502            ),
20503        ]
20504    }
20505
20506    fn added_run(
20507        table_id: u64,
20508        row_count: u64,
20509        min_row_id: u64,
20510        max_row_id: u64,
20511    ) -> crate::wal::AddedRun {
20512        crate::wal::AddedRun {
20513            table_id,
20514            run_id: 7,
20515            row_count,
20516            level: 0,
20517            min_row_id,
20518            max_row_id,
20519            content_hash: [0; 32],
20520        }
20521    }
20522
20523    /// Replays the shared WAL of `db` and returns every record of the one
20524    /// transaction whose commit marker links spilled runs.
20525    fn spilled_commit_records(db: &Database) -> Vec<Record> {
20526        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
20527        let txn_id = records
20528            .iter()
20529            .find_map(|record| match &record.op {
20530                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
20531                _ => None,
20532            })
20533            .expect("a spilled commit is present in the WAL");
20534        records
20535            .into_iter()
20536            .filter(|record| record.txn_id == txn_id)
20537            .collect()
20538    }
20539
20540    #[test]
20541    fn non_spilled_records_translate_byte_identical() {
20542        let records = put_records(1, 0, 2, &[10, 20, 30]);
20543        let translated = translate_records_for_replication(&records).unwrap();
20544        assert_eq!(
20545            bincode::serialize(&translated).unwrap(),
20546            bincode::serialize(&records).unwrap(),
20547            "a commit without spill links must pass through byte-identical"
20548        );
20549    }
20550
20551    #[test]
20552    fn translation_rejects_uncovered_or_malformed_spills() {
20553        // added_runs with no logical spill records at all: rejected.
20554        let mut records = put_records(1, 0, 2, &[10]);
20555        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20556            panic!("put_records ends in TxnCommit");
20557        };
20558        added_runs.push(added_run(0, 1, 10, 10));
20559        let error = translate_records_for_replication(&records).unwrap_err();
20560        assert!(
20561            error.to_string().contains("no logical row records"),
20562            "unexpected error: {error}"
20563        );
20564
20565        // Coverage present but short of the linked row count: rejected.
20566        let mut records = put_records(1, 0, 2, &[10]);
20567        let spilled: Vec<Row> = (0..3_u64)
20568            .map(|value| {
20569                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
20570            })
20571            .collect();
20572        records.insert(
20573            0,
20574            Record::new(
20575                Epoch(0),
20576                1,
20577                Op::SpilledRows {
20578                    table_id: 0,
20579                    rows: bincode::serialize(&spilled).unwrap(),
20580                },
20581            ),
20582        );
20583        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20584            panic!("put_records ends in TxnCommit");
20585        };
20586        added_runs.push(added_run(0, 4, 0, 3));
20587        let error = translate_records_for_replication(&records).unwrap_err();
20588        assert!(
20589            error.to_string().contains("cover 3 rows"),
20590            "unexpected error: {error}"
20591        );
20592
20593        // An undecodable spill payload: rejected at propose time, never at apply.
20594        let mut records = put_records(1, 0, 2, &[10]);
20595        records.insert(
20596            0,
20597            Record::new(
20598                Epoch(0),
20599                1,
20600                Op::SpilledRows {
20601                    table_id: 0,
20602                    rows: vec![0xFF, 0x01, 0x02],
20603                },
20604            ),
20605        );
20606        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20607            panic!("put_records ends in TxnCommit");
20608        };
20609        added_runs.push(added_run(0, 1, 0, 0));
20610        assert!(translate_records_for_replication(&records).is_err());
20611
20612        // Structural violations mirror the apply-side contract.
20613        assert!(translate_records_for_replication(&[]).is_err());
20614        let mut mixed = put_records(1, 0, 2, &[10]);
20615        mixed[0].txn_id = 99;
20616        assert!(translate_records_for_replication(&mixed).is_err());
20617        let mut no_commit = put_records(1, 0, 2, &[10]);
20618        no_commit.pop();
20619        assert!(translate_records_for_replication(&no_commit).is_err());
20620    }
20621
20622    #[test]
20623    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
20624        // A real standalone commit that spills (spec section 8.5).
20625        let leader_dir = tempfile::tempdir().unwrap();
20626        let leader = Database::create(leader_dir.path()).unwrap();
20627        leader.create_table("t", simple_schema()).unwrap();
20628        leader.set_spill_threshold(1);
20629        let table_id = leader.table_id("t").unwrap();
20630        let values: Vec<i64> = (0..60).collect();
20631        leader
20632            .transaction(|txn| {
20633                for value in &values {
20634                    txn.put("t", vec![(1, Value::Int64(*value))])?;
20635                }
20636                Ok(())
20637            })
20638            .unwrap();
20639        assert_eq!(visible_ids(&leader, "t"), values);
20640
20641        // The leader's own WAL keeps the spill shape: SpilledRows records
20642        // plus an added_runs commit marker.
20643        let records = spilled_commit_records(&leader);
20644        assert!(records
20645            .iter()
20646            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
20647        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
20648            panic!("a commit sequence ends in TxnCommit");
20649        };
20650        assert!(!added_runs.is_empty());
20651        let commit_epoch = *epoch;
20652
20653        // Translation strips every run reference and keeps the rows as
20654        // logical puts; the input sequence is untouched.
20655        let translated = translate_records_for_replication(&records).unwrap();
20656        assert!(records
20657            .iter()
20658            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
20659        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
20660            panic!("a commit sequence ends in TxnCommit");
20661        };
20662        assert!(!added_runs.is_empty(), "input records must be unchanged");
20663        assert_eq!(translated.len(), records.len());
20664        assert!(translated
20665            .iter()
20666            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
20667        assert!(translated
20668            .iter()
20669            .any(|record| matches!(record.op, Op::Put { .. })));
20670        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
20671            panic!("a commit sequence ends in TxnCommit");
20672        };
20673        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
20674        assert_eq!(*epoch, commit_epoch);
20675
20676        // The translated payload applies on a replica with identical rows.
20677        let replica_dir = tempfile::tempdir().unwrap();
20678        let replica = Database::create_cluster_replica(
20679            replica_dir.path(),
20680            ClusterId::from_bytes([1; 16]),
20681            NodeId::from_bytes([2; 16]),
20682            DatabaseId::from_bytes([3; 16]),
20683        )
20684        .unwrap();
20685        replica
20686            .apply_replicated_catalog_command(&create_table_record("t", 1))
20687            .unwrap();
20688        assert_eq!(replica.table_id("t").unwrap(), table_id);
20689        assert!(replica.apply_replicated_records(&translated).unwrap());
20690        assert_eq!(visible_ids(&replica, "t"), values);
20691        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
20692
20693        // Standalone behavior is unchanged: the leader still recovers its
20694        // spilled commit by linking the run file.
20695        drop(leader);
20696        let leader = Database::open(leader_dir.path()).unwrap();
20697        assert_eq!(visible_ids(&leader, "t"), values);
20698    }
20699
20700    #[test]
20701    fn staged_txn_writes_validate_and_apply() {
20702        let dir = tempfile::tempdir().unwrap();
20703        let db = Database::create_cluster_replica(
20704            dir.path(),
20705            ClusterId::from_bytes([1; 16]),
20706            NodeId::from_bytes([2; 16]),
20707            DatabaseId::from_bytes([3; 16]),
20708        )
20709        .unwrap();
20710        db.apply_replicated_catalog_command(&create_table_record("t", 1))
20711            .unwrap();
20712        let table_id = db.table_id("t").unwrap();
20713
20714        // Malformed payloads and unmounted tables are rejected at prepare.
20715        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
20716        let unknown_table = StagedTxnWrite::Put {
20717            table_id: 99,
20718            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
20719        }
20720        .encode()
20721        .unwrap();
20722        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
20723        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
20724            .iter()
20725            .map(|value| {
20726                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
20727                    .with_column(1, Value::Int64(*value))];
20728                StagedTxnWrite::Put {
20729                    table_id,
20730                    rows: bincode::serialize(&rows).unwrap(),
20731                }
20732                .encode()
20733                .unwrap()
20734            })
20735            .collect();
20736        db.validate_staged_txn_writes(&good).unwrap();
20737
20738        // A committed resolution applies the staged writes; a delete
20739        // resolution removes them; both are replay-safe.
20740        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
20741            physical_micros: 5_000,
20742            logical: 0,
20743            node_tiebreaker: 0,
20744        };
20745        assert!(db
20746            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
20747            .unwrap());
20748        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
20749        let delete = StagedTxnWrite::Delete {
20750            table_id,
20751            row_ids: vec![20],
20752        }
20753        .encode()
20754        .unwrap();
20755        assert!(db
20756            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
20757            .unwrap());
20758        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
20759
20760        // Restart: the synthetic WAL transactions replay through the same
20761        // recovery path; the rows are durable.
20762        let db = Arc::new(db);
20763        db.shutdown().unwrap();
20764        let expected = crate::storage_mode::StorageMode::ClusterReplica {
20765            cluster_id: ClusterId::from_bytes([1; 16]),
20766            node_id: NodeId::from_bytes([2; 16]),
20767            database_id: DatabaseId::from_bytes([3; 16]),
20768        };
20769        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
20770        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
20771    }
20772}