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
323#[cfg(feature = "encryption")]
324fn read_encryption_salt(
325    root: &crate::durable_file::DurableRoot,
326) -> Result<[u8; crate::encryption::SALT_LEN]> {
327    let mut file = root
328        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
329        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
330    let length = file.metadata()?.len();
331    if length != crate::encryption::SALT_LEN as u64 {
332        return Err(MongrelError::Encryption(format!(
333            "invalid encryption salt length: got {length}, expected {}",
334            crate::encryption::SALT_LEN
335        )));
336    }
337    let mut salt = [0_u8; crate::encryption::SALT_LEN];
338    file.read_exact(&mut salt)?;
339    Ok(salt)
340}
341
342fn incremental_aggregate_cache_key(
343    table: &str,
344    conditions: &[crate::query::Condition],
345    column: Option<u16>,
346    agg: crate::engine::NativeAgg,
347    principal: Option<&crate::auth::Principal>,
348    security_version: u64,
349) -> u64 {
350    use std::hash::{Hash, Hasher};
351    let projection = column.as_ref().map(std::slice::from_ref);
352    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
353    let mut hasher = std::collections::hash_map::DefaultHasher::new();
354    table.hash(&mut hasher);
355    query_key.hash(&mut hasher);
356    match agg {
357        crate::engine::NativeAgg::Count => 0u8,
358        crate::engine::NativeAgg::Sum => 1,
359        crate::engine::NativeAgg::Min => 2,
360        crate::engine::NativeAgg::Max => 3,
361        crate::engine::NativeAgg::Avg => 4,
362    }
363    .hash(&mut hasher);
364    if let Some(principal) = principal {
365        principal.user_id.hash(&mut hasher);
366        principal.created_epoch.hash(&mut hasher);
367        principal.username.hash(&mut hasher);
368        principal.is_admin.hash(&mut hasher);
369        let mut roles = principal.roles.clone();
370        roles.sort_unstable();
371        roles.hash(&mut hasher);
372    }
373    hasher.finish()
374}
375
376fn read_history_retention(
377    root: &crate::durable_file::DurableRoot,
378    current_epoch: Epoch,
379) -> Result<(u64, Epoch)> {
380    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
381    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
382        Ok(file) => file,
383        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
384            return Ok((0, current_epoch));
385        }
386        Err(error) => return Err(error.into()),
387    };
388    let length = file.metadata()?.len();
389    if length > MAX_HISTORY_RETENTION_BYTES {
390        return Err(MongrelError::ResourceLimitExceeded {
391            resource: "history retention bytes",
392            requested: usize::try_from(length).unwrap_or(usize::MAX),
393            limit: MAX_HISTORY_RETENTION_BYTES as usize,
394        });
395    }
396    let mut bytes = Vec::with_capacity(length as usize);
397    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
398        .read_to_end(&mut bytes)?;
399    if bytes.len() as u64 != length {
400        return Err(MongrelError::Other(
401            "history retention length changed while reading".into(),
402        ));
403    }
404    let text = std::str::from_utf8(&bytes)
405        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
406    let mut fields = text.split_whitespace();
407    let epochs = fields
408        .next()
409        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
410        .parse::<u64>()
411        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
412    let start = fields
413        .next()
414        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
415        .parse::<u64>()
416        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
417    if fields.next().is_some() || start > current_epoch.0 {
418        return Err(MongrelError::Other(
419            "history retention file has trailing fields or a future start epoch".into(),
420        ));
421    }
422    Ok((epochs, Epoch(start)))
423}
424
425fn write_history_retention<F>(
426    root: &Path,
427    epochs: u64,
428    start: Epoch,
429    after_publish: F,
430) -> Result<()>
431where
432    F: FnOnce(),
433{
434    let meta = root.join(META_DIR);
435    let path = meta.join(HISTORY_RETENTION_FILENAME);
436    let bytes = format!("{epochs} {}\n", start.0);
437    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
438    Ok(())
439}
440
441struct PreparedBackupDestination {
442    parent: crate::durable_file::DurableRoot,
443    destination_name: std::ffi::OsString,
444    destination_path: PathBuf,
445    stage_name: std::ffi::OsString,
446    stage: Option<Box<crate::durable_file::DurableRoot>>,
447}
448
449fn prepare_backup_destination(
450    source: &Path,
451    destination: &Path,
452) -> Result<PreparedBackupDestination> {
453    let destination_name = destination
454        .file_name()
455        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
456        .to_os_string();
457    let requested_parent = destination
458        .parent()
459        .filter(|path| !path.as_os_str().is_empty())
460        .unwrap_or_else(|| Path::new("."));
461    crate::durable_file::create_directory_all(requested_parent)?;
462    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
463    prepare_backup_destination_in(source, &parent, &destination_name)
464}
465
466fn prepare_backup_destination_in(
467    source: &Path,
468    parent: &crate::durable_file::DurableRoot,
469    destination_name: &std::ffi::OsStr,
470) -> Result<PreparedBackupDestination> {
471    let source = source.canonicalize()?;
472    if parent.canonical_path().starts_with(&source) {
473        return Err(MongrelError::InvalidArgument(
474            "backup destination must not be inside the source database".into(),
475        ));
476    }
477    if parent.entry_exists(Path::new(&destination_name))? {
478        return Err(MongrelError::Conflict(format!(
479            "backup destination already exists: {}",
480            parent.canonical_path().join(destination_name).display()
481        )));
482    }
483    let mut stage_name = None;
484    for _ in 0..128 {
485        let mut nonce = [0_u8; 8];
486        crate::encryption::fill_random(&mut nonce)?;
487        let suffix = nonce
488            .iter()
489            .map(|byte| format!("{byte:02x}"))
490            .collect::<String>();
491        let name = std::ffi::OsString::from(format!(
492            ".{}.backup-stage-{}-{suffix}",
493            destination_name.to_string_lossy(),
494            std::process::id()
495        ));
496        match parent.create_directory_new(Path::new(&name)) {
497            Ok(()) => {
498                stage_name = Some(name);
499                break;
500            }
501            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
502            Err(error) => return Err(error.into()),
503        }
504    }
505    let stage_name = stage_name
506        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
507    let stage = parent.open_directory(Path::new(&stage_name))?;
508    Ok(PreparedBackupDestination {
509        destination_path: parent.canonical_path().join(destination_name),
510        destination_name: destination_name.to_os_string(),
511        stage_name,
512        stage: Some(Box::new(stage)),
513        parent: parent.try_clone()?,
514    })
515}
516
517fn copy_backup_boundary(
518    source_root: &Path,
519    destination_root: &crate::durable_file::DurableRoot,
520    deferred_runs: &HashSet<PathBuf>,
521    copied: &mut Vec<PathBuf>,
522    control: Option<&crate::ExecutionControl>,
523) -> Result<()> {
524    let mut visited = 0;
525    crate::durable_file::walk_regular_files_nofollow(
526        source_root,
527        |relative, is_directory| {
528            if visited % 256 == 0 {
529                if let Some(control) = control {
530                    control.checkpoint()?;
531                }
532            }
533            visited += 1;
534            if backup_path_excluded(relative) {
535                return Ok(false);
536            }
537            if is_directory {
538                return Ok(true);
539            }
540            if deferred_runs.contains(relative) {
541                return Ok(false);
542            }
543            Ok(!(relative
544                .parent()
545                .and_then(Path::file_name)
546                .is_some_and(|parent| parent == "_runs")
547                && relative
548                    .extension()
549                    .is_some_and(|extension| extension == "sr")))
550        },
551        |relative| {
552            destination_root.create_directory_all(relative)?;
553            Ok(())
554        },
555        |relative, source| {
556            destination_root.copy_new_from(relative, source)?;
557            copied.push(relative.to_path_buf());
558            Ok(())
559        },
560    )
561}
562
563fn backup_path_excluded(relative: &Path) -> bool {
564    relative == Path::new("_meta/.lock")
565        || relative == Path::new("_meta/replica")
566        || relative == Path::new("_meta/repl_epoch")
567        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
568        || relative.components().any(|component| {
569            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
570        })
571}
572
573#[derive(Debug, Clone)]
574pub enum ExternalTriggerWrite {
575    Insert {
576        table: String,
577        cells: Vec<(u16, Value)>,
578    },
579    UpdateByPk {
580        table: String,
581        pk: Value,
582        cells: Vec<(u16, Value)>,
583    },
584    DeleteByPk {
585        table: String,
586        pk: Value,
587    },
588}
589
590impl ExternalTriggerWrite {
591    fn table(&self) -> &str {
592        match self {
593            Self::Insert { table, .. }
594            | Self::UpdateByPk { table, .. }
595            | Self::DeleteByPk { table, .. } => table,
596        }
597    }
598}
599
600#[derive(Debug, Clone, PartialEq)]
601pub enum ExternalTriggerBaseWrite {
602    Put {
603        table: String,
604        cells: Vec<(u16, Value)>,
605    },
606    Delete {
607        table: String,
608        row_id: RowId,
609    },
610}
611
612#[derive(Debug, Clone, PartialEq)]
613pub struct ExternalTriggerWriteResult {
614    pub state: Vec<u8>,
615    pub base_writes: Vec<ExternalTriggerBaseWrite>,
616}
617
618impl ExternalTriggerWriteResult {
619    pub fn new(state: Vec<u8>) -> Self {
620        Self {
621            state,
622            base_writes: Vec::new(),
623        }
624    }
625}
626
627pub trait ExternalTriggerBridge: Send + Sync {
628    fn apply_trigger_external_write(
629        &self,
630        entry: &ExternalTableEntry,
631        base_state: Vec<u8>,
632        op: ExternalTriggerWrite,
633    ) -> Result<ExternalTriggerWriteResult>;
634}
635
636/// A pending uniform-epoch run written during a large transaction (spec §8.5).
637struct SpilledRun {
638    table_id: u64,
639    run_id: u128,
640    pending_path: PathBuf,
641    final_path: PathBuf,
642    rows: Vec<crate::memtable::Row>,
643    row_count: u64,
644    min_rid: u64,
645    max_rid: u64,
646    content_hash: [u8; 32],
647}
648
649const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
650const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
651
652fn encode_spilled_row_chunks(
653    rows: &[crate::memtable::Row],
654    total_bytes: &mut usize,
655    total_limit: usize,
656    control: Option<&crate::ExecutionControl>,
657) -> Result<Vec<Vec<u8>>> {
658    let mut output = Vec::new();
659    let mut start = 0;
660    while start < rows.len() {
661        // Bincode's sequence length prefix is a u64 with the workspace's
662        // fixed-int options. `serialized_size` computes exact row sizes
663        // without first allocating one transaction-sized buffer.
664        let mut estimated_bytes = std::mem::size_of::<u64>();
665        let mut end = start;
666        while end < rows.len() {
667            if end % 256 == 0 {
668                if let Some(control) = control {
669                    control.checkpoint()?;
670                }
671            }
672            let row_bytes =
673                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
674                    MongrelError::ResourceLimitExceeded {
675                        resource: "spilled WAL row bytes",
676                        requested: usize::MAX,
677                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
678                    }
679                })?;
680            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
681                MongrelError::ResourceLimitExceeded {
682                    resource: "spilled WAL row bytes",
683                    requested: usize::MAX,
684                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
685                },
686            )?;
687            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
688                break;
689            }
690            estimated_bytes = next_bytes;
691            end += 1;
692        }
693        if end == start {
694            return Err(MongrelError::ResourceLimitExceeded {
695                resource: "spilled WAL row bytes",
696                requested: estimated_bytes.saturating_add(1),
697                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
698            });
699        }
700        let payload = bincode::serialize(&rows[start..end])?;
701        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
702            return Err(MongrelError::ResourceLimitExceeded {
703                resource: "spilled WAL row bytes",
704                requested: payload.len(),
705                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
706            });
707        }
708        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
709        if requested > total_limit {
710            return Err(MongrelError::ResourceLimitExceeded {
711                resource: "spilled WAL transaction bytes",
712                requested,
713                limit: total_limit,
714            });
715        }
716        *total_bytes = requested;
717        output.push(payload);
718        start = end;
719    }
720    Ok(output)
721}
722
723#[cfg(test)]
724mod spilled_wal_encoding_tests {
725    use super::*;
726
727    #[test]
728    fn logical_spill_payload_has_a_total_bound() {
729        let rows = (0..4)
730            .map(|row_id| crate::memtable::Row {
731                row_id: crate::rowid::RowId(row_id),
732                committed_epoch: Epoch::ZERO,
733                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
734                deleted: false,
735            })
736            .collect::<Vec<_>>();
737        let mut total = 0;
738        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
739        assert!(matches!(
740            error,
741            MongrelError::ResourceLimitExceeded {
742                resource: "spilled WAL transaction bytes",
743                ..
744            }
745        ));
746    }
747}
748
749/// Move spill files to their final names before the WAL commit. Dropping this
750/// guard restores pending names while commit is still known not to have begun.
751/// It is disarmed immediately before the first WAL append, where the outcome
752/// can become ambiguous and recovery may need the final names.
753struct PreparedRunLinks {
754    links: Vec<(PathBuf, PathBuf)>,
755    armed: bool,
756}
757
758impl PreparedRunLinks {
759    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
760        let mut guard = Self {
761            links: Vec::with_capacity(spilled.len()),
762            armed: true,
763        };
764        for run in spilled {
765            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
766            guard
767                .links
768                .push((run.pending_path.clone(), run.final_path.clone()));
769        }
770        Ok(guard)
771    }
772
773    fn disarm(&mut self) {
774        self.armed = false;
775        for (pending, _) in &self.links {
776            if let Some(parent) = pending.parent() {
777                let _ = std::fs::remove_dir_all(parent);
778            }
779        }
780    }
781}
782
783impl Drop for PreparedRunLinks {
784    fn drop(&mut self) {
785        if !self.armed {
786            return;
787        }
788        for (pending, final_path) in self.links.iter().rev() {
789            let _ = std::fs::rename(final_path, pending);
790        }
791    }
792}
793
794struct TableApplyBatch {
795    table_id: u64,
796    handle: TableHandle,
797    ops: Vec<crate::txn::StagedOp>,
798}
799
800#[derive(Debug, Clone)]
801struct TriggerRowImage {
802    columns: HashMap<u16, Value>,
803}
804
805impl TriggerRowImage {
806    fn from_row(row: crate::memtable::Row) -> Self {
807        Self {
808            columns: row.columns,
809        }
810    }
811
812    fn from_cells(cells: &[(u16, Value)]) -> Self {
813        Self {
814            columns: cells.iter().cloned().collect(),
815        }
816    }
817}
818
819#[derive(Debug, Clone)]
820struct WriteEvent {
821    table: String,
822    kind: TriggerEvent,
823    old: Option<TriggerRowImage>,
824    new: Option<TriggerRowImage>,
825    changed_columns: Vec<u16>,
826    op_indices: Vec<usize>,
827    put_idx: Option<usize>,
828    trigger_stack: Vec<String>,
829}
830
831#[derive(Default)]
832struct TriggerExpansion {
833    before: Vec<(u64, crate::txn::Staged)>,
834    before_stacks: Vec<Vec<String>>,
835    before_external: Vec<ExternalTriggerWrite>,
836    after: Vec<(u64, crate::txn::Staged)>,
837    after_stacks: Vec<Vec<String>>,
838    after_external: Vec<ExternalTriggerWrite>,
839    ignored_indices: std::collections::BTreeSet<usize>,
840}
841
842#[derive(Clone, PartialEq)]
843struct TriggerCatalogBinding {
844    triggers: Vec<TriggerEntry>,
845    tables: Vec<(String, u64, u64)>,
846    external_tables: Vec<(String, u64, u64)>,
847}
848
849fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
850    let mut triggers = catalog
851        .triggers
852        .iter()
853        .filter(|entry| entry.trigger.enabled)
854        .cloned()
855        .collect::<Vec<_>>();
856    if triggers.is_empty() {
857        return None;
858    }
859    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
860    let mut tables = catalog
861        .tables
862        .iter()
863        .filter(|entry| matches!(entry.state, TableState::Live))
864        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
865        .collect::<Vec<_>>();
866    tables.sort_unstable();
867    let mut external_tables = catalog
868        .external_tables
869        .iter()
870        .map(|entry| {
871            (
872                entry.name.clone(),
873                entry.created_epoch,
874                entry.declared_schema.schema_id,
875            )
876        })
877        .collect::<Vec<_>>();
878    external_tables.sort_unstable();
879    Some(TriggerCatalogBinding {
880        triggers,
881        tables,
882        external_tables,
883    })
884}
885
886struct TriggerProgramOutput<'a> {
887    added: &'a mut Vec<(u64, crate::txn::Staged)>,
888    added_stacks: &'a mut Vec<Vec<String>>,
889    added_external: &'a mut Vec<ExternalTriggerWrite>,
890    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
891}
892
893#[derive(Debug, Clone, Copy, PartialEq, Eq)]
894enum TriggerProgramOutcome {
895    Continue,
896    Ignore,
897}
898
899/// An integrity issue found by [`Database::check`] (spec §16).
900#[derive(Debug, Clone)]
901pub struct CheckIssue {
902    pub table_id: u64,
903    pub table_name: String,
904    pub severity: String,
905    pub description: String,
906}
907
908/// One optimistic authorization snapshot for a complete scored read.
909#[derive(Debug, Clone)]
910pub struct AuthorizedReadSnapshot {
911    pub table: String,
912    pub table_snapshot: Snapshot,
913    pub data_generation: u64,
914    pub security_version: u64,
915    pub allowed_row_ids: Option<HashSet<RowId>>,
916}
917
918/// Exact table/security generation used by one successful authorized read.
919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
920pub struct AuthorizedReadStamp {
921    pub table_id: u64,
922    pub schema_id: u64,
923    pub data_generation: u64,
924    pub security_version: u64,
925    pub snapshot: Snapshot,
926}
927
928type RlsCacheKey = (String, u64, u64, String);
929
930/// Runtime statistics for the byte-bounded RLS candidate cache.
931#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
932pub struct RlsCacheStats {
933    pub entries: usize,
934    pub bytes: usize,
935    pub hits: u64,
936    pub misses: u64,
937    pub evictions: u64,
938    pub build_nanos: u64,
939    pub rows_evaluated: u64,
940}
941
942const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
943const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
944const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
945const CDC_MAX_EVENTS: usize = 100_000;
946const CDC_MAX_ROWS: usize = 1_000_000;
947const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
948const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
949
950fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
951    let requested = total.saturating_add(amount);
952    if requested > CDC_MAX_RETAINED_BYTES {
953        return Err(MongrelError::ResourceLimitExceeded {
954            resource,
955            requested,
956            limit: CDC_MAX_RETAINED_BYTES,
957        });
958    }
959    *total = requested;
960    Ok(())
961}
962
963fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
964    usize::try_from(row.estimated_bytes())
965        .unwrap_or(usize::MAX)
966        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
967}
968
969fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
970    let value_slot = std::mem::size_of::<serde_json::Value>();
971    row.columns.values().fold(512_usize, |bytes, value| {
972        let values = match value {
973            Value::Bytes(values) => values.len(),
974            Value::Json(values) => values.len(),
975            Value::Embedding(values) => values.len(),
976            _ => 1,
977        };
978        bytes.saturating_add(values.saturating_mul(value_slot))
979    })
980}
981
982fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
983    rows.iter().fold(0_usize, |bytes, row| {
984        bytes.saturating_add(cdc_row_json_bytes(row))
985    })
986}
987
988#[derive(Default)]
989struct RlsCache {
990    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
991    lru: VecDeque<RlsCacheKey>,
992    bytes: usize,
993    hits: u64,
994    misses: u64,
995    evictions: u64,
996    build_nanos: u64,
997    rows_evaluated: u64,
998}
999
1000impl RlsCache {
1001    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
1002        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
1003        if value.is_some() {
1004            self.hits = self.hits.saturating_add(1);
1005            self.touch(key);
1006        } else {
1007            self.misses = self.misses.saturating_add(1);
1008        }
1009        value
1010    }
1011
1012    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
1013        let bytes = key
1014            .0
1015            .len()
1016            .saturating_add(key.3.len())
1017            .saturating_add(
1018                value
1019                    .capacity()
1020                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
1021            )
1022            .saturating_add(std::mem::size_of::<RlsCacheKey>());
1023        if bytes > RLS_CACHE_MAX_BYTES {
1024            return;
1025        }
1026        if let Some((_, old_bytes)) = self.entries.remove(&key) {
1027            self.bytes = self.bytes.saturating_sub(old_bytes);
1028        }
1029        self.lru.retain(|candidate| candidate != &key);
1030        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
1031            let Some(oldest) = self.lru.pop_front() else {
1032                break;
1033            };
1034            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
1035                self.bytes = self.bytes.saturating_sub(old_bytes);
1036                self.evictions = self.evictions.saturating_add(1);
1037            }
1038        }
1039        self.bytes = self.bytes.saturating_add(bytes);
1040        self.lru.push_back(key.clone());
1041        self.entries.insert(key, (value, bytes));
1042    }
1043
1044    fn touch(&mut self, key: &RlsCacheKey) {
1045        self.lru.retain(|candidate| candidate != key);
1046        self.lru.push_back(key.clone());
1047    }
1048
1049    fn stats(&self) -> RlsCacheStats {
1050        RlsCacheStats {
1051            entries: self.entries.len(),
1052            bytes: self.bytes,
1053            hits: self.hits,
1054            misses: self.misses,
1055            evictions: self.evictions,
1056            build_nanos: self.build_nanos,
1057            rows_evaluated: self.rows_evaluated,
1058        }
1059    }
1060}
1061
1062/// Mounted table with immutable, structurally shared scored-read generations.
1063#[derive(Clone)]
1064pub struct TableHandle {
1065    inner: TableHandleInner,
1066    generation_metrics: Arc<TableGenerationMetrics>,
1067}
1068
1069#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1070pub struct TableGenerationStats {
1071    pub active_read_generations: usize,
1072    pub max_live_read_generations: usize,
1073    pub cow_clone_count: u64,
1074    pub cow_clone_nanos: u64,
1075    pub estimated_cow_clone_bytes: u64,
1076    pub writer_wait_nanos: u64,
1077}
1078
1079#[derive(Default)]
1080#[doc(hidden)]
1081pub struct TableGenerationMetrics {
1082    active_read_generations: AtomicUsize,
1083    max_live_read_generations: AtomicUsize,
1084    cow_clone_count: AtomicU64,
1085    cow_clone_nanos: AtomicU64,
1086    estimated_cow_clone_bytes: AtomicU64,
1087    writer_wait_nanos: AtomicU64,
1088}
1089
1090impl TableGenerationMetrics {
1091    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
1092        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
1093        self.max_live_read_generations
1094            .fetch_max(active, Ordering::Relaxed);
1095        Arc::new(TableReadGeneration {
1096            table,
1097            metrics: Arc::clone(self),
1098        })
1099    }
1100
1101    fn stats(&self) -> TableGenerationStats {
1102        TableGenerationStats {
1103            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
1104            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
1105            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
1106            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
1107            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
1108            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
1109        }
1110    }
1111}
1112
1113/// Immutable, structurally shared snapshot used by scored readers.
1114pub struct TableReadGeneration {
1115    table: Table,
1116    metrics: Arc<TableGenerationMetrics>,
1117}
1118
1119impl std::ops::Deref for TableReadGeneration {
1120    type Target = Table;
1121
1122    fn deref(&self) -> &Self::Target {
1123        &self.table
1124    }
1125}
1126
1127impl Drop for TableReadGeneration {
1128    fn drop(&mut self) {
1129        self.metrics
1130            .active_read_generations
1131            .fetch_sub(1, Ordering::Relaxed);
1132    }
1133}
1134
1135#[derive(Clone)]
1136enum TableHandleInner {
1137    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
1138    Direct(Arc<Mutex<Table>>),
1139}
1140
1141pub enum TableGuard<'a> {
1142    CopyOnWrite {
1143        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
1144        metrics: Arc<TableGenerationMetrics>,
1145    },
1146    Direct {
1147        table: parking_lot::MutexGuard<'a, Table>,
1148    },
1149}
1150
1151impl TableHandle {
1152    fn new(table: Table) -> Self {
1153        Self {
1154            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
1155            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1156        }
1157    }
1158
1159    pub fn from_table(table: Table) -> Self {
1160        Self::new(table)
1161    }
1162
1163    pub fn lock(&self) -> TableGuard<'_> {
1164        let started = std::time::Instant::now();
1165        let guard = match &self.inner {
1166            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
1167                table: table.write(),
1168                metrics: Arc::clone(&self.generation_metrics),
1169            },
1170            TableHandleInner::Direct(table) => TableGuard::Direct {
1171                table: table.lock(),
1172            },
1173        };
1174        self.generation_metrics.writer_wait_nanos.fetch_add(
1175            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1176            Ordering::Relaxed,
1177        );
1178        guard
1179    }
1180
1181    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
1182        let started = std::time::Instant::now();
1183        let guard = match &self.inner {
1184            TableHandleInner::CopyOnWrite(table) => {
1185                table
1186                    .try_write_for(timeout)
1187                    .map(|table| TableGuard::CopyOnWrite {
1188                        table,
1189                        metrics: Arc::clone(&self.generation_metrics),
1190                    })
1191            }
1192            TableHandleInner::Direct(table) => table
1193                .try_lock_for(timeout)
1194                .map(|table| TableGuard::Direct { table }),
1195        };
1196        self.generation_metrics.writer_wait_nanos.fetch_add(
1197            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1198            Ordering::Relaxed,
1199        );
1200        guard
1201    }
1202
1203    pub fn ptr_eq(&self, other: &Self) -> bool {
1204        match (&self.inner, &other.inner) {
1205            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1206                Arc::ptr_eq(left, right)
1207            }
1208            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1209                Arc::ptr_eq(left, right)
1210            }
1211            _ => false,
1212        }
1213    }
1214
1215    pub fn read_generation_with_context(
1216        &self,
1217        context: Option<&crate::query::AiExecutionContext>,
1218    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1219        let mut table = if let Some(context) = context {
1220            loop {
1221                context.checkpoint()?;
1222                let wait = context
1223                    .remaining_duration()
1224                    .unwrap_or(std::time::Duration::from_millis(5))
1225                    .min(std::time::Duration::from_millis(5));
1226                if let Some(table) = self.try_lock_for(wait) {
1227                    break table;
1228                }
1229            }
1230        } else {
1231            self.lock()
1232        };
1233        let snapshot = table.snapshot();
1234        let generation = table.clone_read_generation()?;
1235        Ok((self.generation_metrics.activate(generation), snapshot))
1236    }
1237
1238    pub fn generation_stats(&self) -> TableGenerationStats {
1239        self.generation_metrics.stats()
1240    }
1241}
1242
1243impl From<Arc<Mutex<Table>>> for TableHandle {
1244    fn from(table: Arc<Mutex<Table>>) -> Self {
1245        Self {
1246            inner: TableHandleInner::Direct(table),
1247            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1248        }
1249    }
1250}
1251
1252impl std::ops::Deref for TableGuard<'_> {
1253    type Target = Table;
1254
1255    fn deref(&self) -> &Self::Target {
1256        match self {
1257            Self::CopyOnWrite { table, .. } => table.as_ref(),
1258            Self::Direct { table } => table,
1259        }
1260    }
1261}
1262
1263impl std::ops::DerefMut for TableGuard<'_> {
1264    fn deref_mut(&mut self) -> &mut Self::Target {
1265        match self {
1266            Self::CopyOnWrite { table, metrics } => {
1267                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1268                    let estimated_bytes = table.estimated_clone_bytes();
1269                    let started = std::time::Instant::now();
1270                    let table = Arc::make_mut(table);
1271                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1272                    metrics.cow_clone_nanos.fetch_add(
1273                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1274                        Ordering::Relaxed,
1275                    );
1276                    metrics
1277                        .estimated_cow_clone_bytes
1278                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1279                    table
1280                } else {
1281                    Arc::make_mut(table)
1282                }
1283            }
1284            Self::Direct { table } => table,
1285        }
1286    }
1287}
1288
1289#[derive(Clone, Debug)]
1290pub struct ReadAuthorization {
1291    pub operation: crate::auth::ColumnOperation,
1292    pub columns: Vec<u16>,
1293    pub permissions: Vec<crate::auth::Permission>,
1294}
1295
1296#[derive(Default, Debug)]
1297struct TableWritePermissionNeeds {
1298    insert: bool,
1299    insert_columns: Vec<u16>,
1300    update: bool,
1301    update_columns: Vec<u16>,
1302    delete: bool,
1303    truncate: bool,
1304}
1305
1306#[cfg(test)]
1307thread_local! {
1308    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1309    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1310    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1311    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1312    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1313    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1314}
1315
1316fn summarize_write_permissions(
1317    staging: &[(u64, crate::txn::Staged)],
1318) -> HashMap<u64, TableWritePermissionNeeds> {
1319    use crate::txn::Staged;
1320
1321    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1322    for (table_id, operation) in staging {
1323        let table = needs.entry(*table_id).or_default();
1324        match operation {
1325            Staged::Put(cells) => {
1326                table.insert = true;
1327                table
1328                    .insert_columns
1329                    .extend(cells.iter().map(|(column, _)| *column));
1330            }
1331            Staged::Update {
1332                changed_columns, ..
1333            } => {
1334                table.update = true;
1335                table.update_columns.extend(changed_columns);
1336            }
1337            Staged::Delete(_) => table.delete = true,
1338            Staged::Truncate => table.truncate = true,
1339        }
1340    }
1341    for table in needs.values_mut() {
1342        table.insert_columns.sort_unstable();
1343        table.insert_columns.dedup();
1344        table.update_columns.sort_unstable();
1345        table.update_columns.dedup();
1346    }
1347    needs
1348}
1349
1350struct SecurityCoordinator {
1351    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1352    gate: RwLock<()>,
1353    version: AtomicU64,
1354}
1355
1356fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1357    static COORDINATORS: std::sync::OnceLock<
1358        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1359    > = std::sync::OnceLock::new();
1360
1361    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1362    let mut coordinators = COORDINATORS
1363        .get_or_init(|| Mutex::new(HashMap::new()))
1364        .lock();
1365    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1366    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1367        return coordinator;
1368    }
1369    let coordinator = Arc::new(SecurityCoordinator {
1370        gate: RwLock::new(()),
1371        version: AtomicU64::new(version),
1372    });
1373    coordinators.insert(root, Arc::downgrade(&coordinator));
1374    coordinator
1375}
1376
1377pub fn lock_table_with_context<'a>(
1378    handle: &'a TableHandle,
1379    context: Option<&crate::query::AiExecutionContext>,
1380) -> Result<TableGuard<'a>> {
1381    let Some(context) = context else {
1382        return Ok(handle.lock());
1383    };
1384    loop {
1385        context.checkpoint()?;
1386        let wait = context
1387            .remaining_duration()
1388            .unwrap_or(std::time::Duration::from_millis(5))
1389            .min(std::time::Duration::from_millis(5));
1390        if let Some(guard) = handle.try_lock_for(wait) {
1391            return Ok(guard);
1392        }
1393    }
1394}
1395
1396/// Knobs for [`Database::open_with_options`].
1397///
1398/// All fields default to the same values the convenience
1399/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1400/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1401#[derive(Clone, Debug, Default)]
1402pub struct OpenOptions {
1403    /// Maximum time, in milliseconds, to wait for the cross-process database
1404    /// lock (`_meta/.lock`) before failing with `MongrelError::DatabaseLocked`.
1405    ///
1406    /// `0` (the default) preserves the historical fail-fast semantics: a
1407    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1408    /// `busy_timeout` semantics kick in once this is non-zero — the open
1409    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1410    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1411    /// point the open returns the same typed lock error as the fail-fast path.
1412    ///
1413    /// Only the cross-process lock is affected. Mounted tables, page-cache
1414    /// misses, and WAL appends already serialize through in-process locks
1415    /// that handle their own contention. A second independent open in the
1416    /// same process always returns `DatabaseLocked` immediately; share the
1417    /// existing `Arc<Database>` instead.
1418    pub lock_timeout_ms: u32,
1419    /// Total bytes the storage core's [`crate::memory::MemoryGovernor`] may
1420    /// hand out across every memory class (S1E-003). `None` (the default)
1421    /// uses [`DEFAULT_MEMORY_BUDGET_BYTES`]. The governor is a reservation
1422    /// accounting layer, not an allocation: this is a cap, not a preallocation.
1423    pub memory_budget_bytes: Option<u64>,
1424    /// Total bytes of live spill files the core's
1425    /// [`crate::spill::SpillManager`] allows across every query (S1E-004).
1426    /// `None` (the default) uses [`DEFAULT_TEMP_DISK_BUDGET_BYTES`]. Again a
1427    /// cap, not a preallocation.
1428    pub temp_disk_budget_bytes: Option<u64>,
1429    /// Open the database through the special offline-validation API (spec
1430    /// section 5.3): any [`crate::storage_mode::StorageMode`] — including
1431    /// `ClusterReplica`, which every normal open path rejects — is opened
1432    /// **read-only** so a backup validator can inspect it. The opened core
1433    /// rejects every write with [`MongrelError::ReadOnlyReplica`]. This is the
1434    /// only way to open a cluster replica outside the cluster node runtime.
1435    pub offline_validation: bool,
1436}
1437
1438impl OpenOptions {
1439    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1440    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1441    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1442        self.lock_timeout_ms = ms;
1443        self
1444    }
1445
1446    /// Set [`OpenOptions::memory_budget_bytes`].
1447    pub fn with_memory_budget_bytes(mut self, bytes: u64) -> Self {
1448        self.memory_budget_bytes = Some(bytes);
1449        self
1450    }
1451
1452    /// Set [`OpenOptions::temp_disk_budget_bytes`].
1453    pub fn with_temp_disk_budget_bytes(mut self, bytes: u64) -> Self {
1454        self.temp_disk_budget_bytes = Some(bytes);
1455        self
1456    }
1457
1458    /// Set [`OpenOptions::offline_validation`]. `true` opens any storage mode
1459    /// read-only (the offline backup-validator API of spec section 5.3).
1460    pub fn with_offline_validation(mut self, offline_validation: bool) -> Self {
1461        self.offline_validation = offline_validation;
1462        self
1463    }
1464}
1465
1466/// How an open/create path treats the durable storage-mode marker (spec
1467/// section 5.3, Stage 2E). Threaded from the public constructors through the
1468/// open chain into [`Database::finish_open`].
1469#[derive(Debug, Clone)]
1470pub(crate) enum OpenModeGate {
1471    /// Normal embedded/server open: `ClusterReplica` markers are rejected with
1472    /// [`crate::storage_mode::StorageModeError::ClusterReplicaRequiresClusterRuntime`].
1473    Normal,
1474    /// Offline backup validator: any mode opens, forced read-only.
1475    OfflineValidation,
1476    /// Cluster node runtime: the marker must exist and equal this
1477    /// `ClusterReplica` identity; the core opens read-only for user paths (all
1478    /// writes arrive through the replicated apply path).
1479    ClusterRuntime {
1480        /// Expected owning cluster.
1481        cluster_id: mongreldb_types::ids::ClusterId,
1482        /// Expected owning node.
1483        node_id: mongreldb_types::ids::NodeId,
1484        /// Expected replicated database.
1485        database_id: mongreldb_types::ids::DatabaseId,
1486    },
1487    /// Fresh create: no marker may exist yet; this mode is written.
1488    Create(crate::storage_mode::StorageMode),
1489}
1490
1491/// Default node-level memory budget (S1E-003): 1 GiB. Comfortably covers the
1492/// two 64 MiB caches with headroom for query execution, result buffering, and
1493/// maintenance reservations.
1494pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
1495
1496/// Default node-level temporary-disk (spill) budget (S1E-004): 4 GiB of live
1497/// spill files across every query on the core.
1498pub const DEFAULT_TEMP_DISK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
1499
1500/// Resolved node-level resource budgets for one storage core
1501/// (S1E-003/S1E-004), threaded from [`OpenOptions`] through the open chain
1502/// into [`Database::finish_open`].
1503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1504pub(crate) struct CoreResourceConfig {
1505    pub memory_budget_bytes: u64,
1506    pub temp_disk_budget_bytes: u64,
1507}
1508
1509impl Default for CoreResourceConfig {
1510    fn default() -> Self {
1511        Self {
1512            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
1513            temp_disk_budget_bytes: DEFAULT_TEMP_DISK_BUDGET_BYTES,
1514        }
1515    }
1516}
1517
1518impl CoreResourceConfig {
1519    fn from_options(options: &OpenOptions) -> Result<Self> {
1520        let config = Self {
1521            memory_budget_bytes: options
1522                .memory_budget_bytes
1523                .unwrap_or(DEFAULT_MEMORY_BUDGET_BYTES),
1524            temp_disk_budget_bytes: options
1525                .temp_disk_budget_bytes
1526                .unwrap_or(DEFAULT_TEMP_DISK_BUDGET_BYTES),
1527        };
1528        if config.memory_budget_bytes == 0 {
1529            return Err(MongrelError::InvalidArgument(
1530                "memory_budget_bytes must be nonzero".into(),
1531            ));
1532        }
1533        if config.temp_disk_budget_bytes == 0 {
1534            return Err(MongrelError::InvalidArgument(
1535                "temp_disk_budget_bytes must be nonzero".into(),
1536            ));
1537        }
1538        Ok(config)
1539    }
1540}
1541
1542/// Per-table version-retention pin diagnostics (S1C-004): one mounted table's
1543/// active pin sources as reported by [`crate::engine::Table::version_pins_report`].
1544#[derive(Debug, Clone)]
1545pub struct TablePinsReport {
1546    /// The mounted table's id.
1547    pub table_id: u64,
1548    /// The mounted table's catalog name.
1549    pub table: String,
1550    /// Every active version-retention pin source on the table.
1551    pub pins: crate::retention::PinsReport,
1552}
1553
1554/// The shared storage core (spec §10.1, S1A-001): one catalog, one epoch
1555/// clock, shared caches, a shared WAL, and a live map of name → `Arc<Table>`.
1556///
1557/// `DatabaseCore` owns every storage resource — the durable root, the
1558/// exclusive lease, the catalog, the commit log, the epoch authority, the
1559/// transaction state, the mounted tables, and the page caches — plus the
1560/// lifecycle state machine (S1A-004). It is shared through an `Arc`: the
1561/// exclusive [`Database`] owner holds one reference, and every
1562/// [`crate::handle::DatabaseHandle`] issued by
1563/// [`crate::manager::DatabaseManager`] holds another. The core deliberately
1564/// does **not** store one mutable "current principal" (spec §4.6): per-caller
1565/// identity lives on the handle types, not inside shared storage state.
1566pub struct DatabaseCore {
1567    root: PathBuf,
1568    durable_root: Arc<crate::durable_file::DurableRoot>,
1569    /// Lifecycle state machine (spec §10.1, S1A-004). Shared through an `Arc`
1570    /// so [`crate::core::OperationGuard`]s are `'static` and thread-safe.
1571    lifecycle: Arc<crate::core::LifecycleController>,
1572    /// Set by [`crate::manager::DatabaseManager`] when this core backs shared
1573    /// handles: lets `shutdown()` transition the registry entry through
1574    /// `Closing` to removal. `None` for exclusively-owned cores.
1575    registry: std::sync::OnceLock<crate::manager::CoreRegistration>,
1576    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1577    read_only: bool,
1578    catalog: RwLock<Catalog>,
1579    security_coordinator: Arc<SecurityCoordinator>,
1580    security_catalog_disk_reads: AtomicU64,
1581    rls_cache: Mutex<RlsCache>,
1582    epoch: Arc<EpochAuthority>,
1583    snapshots: Arc<SnapshotRegistry>,
1584    /// S1E-003: the node-level memory governor for this core. Exactly one per
1585    /// core; both caches below hold reservations under it and are registered
1586    /// as reclaimable subsystems it can evict under pressure (escalation
1587    /// step 2).
1588    memory_governor: crate::memory::MemoryGovernor,
1589    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1590    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1591    /// S1E-004: the node-level spill manager, rooted at `<db-root>/temp/spill`
1592    /// and sealed with the database meta DEK when encryption is enabled. Its
1593    /// startup sweep ran during open; per-query sessions draw from it.
1594    spill_manager: crate::spill::SpillManager,
1595    /// S1E-002: workload resource groups for admission (concurrency, memory,
1596    /// temp disk, work units). Seeded with class defaults at open; operators
1597    /// reconfigure through [`Database::resource_groups`].
1598    resource_groups: crate::resource::ResourceGroupRegistry,
1599    /// Optional pluggable embedding providers (local models / registered
1600    /// backends). Empty by default — dense ANN uses application-supplied
1601    /// vectors; sparse retrieval needs no provider.
1602    embedding_providers: crate::embedding::EmbeddingProviderRegistry,
1603    /// S1F-002: the durable job registry (sibling `JOBS` file). Exactly one
1604    /// per core; jobs recovered mid-`Running` by a crash come back `Paused`.
1605    job_registry: Arc<crate::jobs::JobRegistry>,
1606    commit_lock: Arc<Mutex<()>>,
1607    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1608    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1609    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1610    /// writes also land in this one WAL (B1 — one WAL per database).
1611    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1612    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1613    /// in P2.7; here it just needs to be unique within an open. Shared with
1614    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1615    next_txn_id: Arc<Mutex<u64>>,
1616    tables: RwLock<HashMap<u64, TableHandle>>,
1617    kek: Option<Arc<crate::encryption::Kek>>,
1618    /// Serializes DDL (create/drop table); data commits serialize through
1619    /// `commit_lock` shared via `SharedCtx`.
1620    ddl_lock: Mutex<()>,
1621    meta_dek: Option<[u8; META_DEK_LEN]>,
1622    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1623    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1624    spill_threshold: std::sync::atomic::AtomicU64,
1625    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1626    /// detection (spec §9.2).
1627    conflicts: crate::txn::ConflictIndex,
1628    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1629    /// pruning (spec §9.2, review fix #12).
1630    active_txns: crate::txn::ActiveTxns,
1631    /// S1B-003: the core's key/predicate lock manager. One per core; commits
1632    /// acquire unique-constraint key claims, FK parent-protection holds,
1633    /// sequence-allocation barriers, and the DML Shared hold on the schema
1634    /// barrier (DDL takes it Exclusive), all released when the transaction
1635    /// ends.
1636    lock_manager: Arc<crate::locks::LockManager>,
1637    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1638    /// Shared with mounted tables so a single-table commit also honors poison.
1639    poisoned: Arc<std::sync::atomic::AtomicBool>,
1640    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1641    /// but defers the fsync to one leader here, so concurrent commits share a
1642    /// single fsync (spec §9.3). Shared with mounted tables.
1643    group: Arc<crate::txn::GroupCommit>,
1644    /// FND-004 (spec §9.4): the standalone commit-log authority wrapping
1645    /// `shared_wal` + `group`. The commit sequencer routes WAL appends and
1646    /// group-commit durability through it, and reader-visibility publication
1647    /// (`publish_in_order`) is gated on its commit receipts.
1648    commit_log: Arc<crate::commit_log::StandaloneCommitLog>,
1649    /// The node's single HLC timestamp authority (spec §4.1, §8.2; ADR-0003).
1650    /// Transaction read timestamps are captured at `begin`, and commit
1651    /// timestamps are allocated here under the sequencer lock (strictly after
1652    /// every participant read/write timestamp). `Epoch` remains the
1653    /// reader-visibility counter during the dual-model migration.
1654    hlc: Arc<mongreldb_types::hlc::HlcClock>,
1655    /// S1B-005: durable idempotency ledger for keyed remote writes, persisted
1656    /// in the sibling `TXN_IDEMPOTENCY` file (mirroring `jobs.rs`'s `JOBS`).
1657    idempotency: crate::txn::IdempotencyLedger,
1658    /// Per-open epoch → commit-timestamp ledger backing
1659    /// [`Database::commit_ts_for_epoch`]. Commits sealed within this open
1660    /// record the exact receipt `HlcTimestamp`; commits recovered from the
1661    /// durable `Op::CommitTimestamp` WAL ledger at open reconstruct the
1662    /// physical component only (`logical`/`node_tiebreaker` are 0 — the
1663    /// ledger byte format stores micros). Bounded to the newest
1664    /// [`COMMIT_TS_LEDGER_CAP`] epochs.
1665    commit_ts_ledger: Mutex<std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp>>,
1666    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1667    /// live spill's pending dir (review fix #14, spec §6.4).
1668    active_spills: Arc<crate::retention::ActiveSpills>,
1669    /// A write lock captures a consistent bootstrap image; transaction commits
1670    /// hold a read lock across spill preparation, WAL append, and publish.
1671    replication_barrier: parking_lot::RwLock<()>,
1672    /// Number of rotated WAL segments retained for lagging followers.
1673    replication_wal_retention_segments: AtomicUsize,
1674    /// Live immutable run files used by online backups or scored read
1675    /// generations. GC cannot unlink them until every owning guard drops.
1676    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1677    /// Test-only barrier invoked after a transaction writes its spill runs but
1678    /// before the sequencer/publish, so tests can race `gc()` against an
1679    /// in-flight spill. `None` in production.
1680    #[doc(hidden)]
1681    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1682    /// Test seam after the security read gate is held and before WAL append.
1683    #[doc(hidden)]
1684    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1685    /// Test seam after transaction preparation and before catalog generation
1686    /// validation under the commit sequencer.
1687    #[doc(hidden)]
1688    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1689    /// Test seam after a backup boundary is captured and before pinned runs are
1690    /// copied. Lets tests compact+GC the source at the worst possible moment.
1691    #[doc(hidden)]
1692    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1693    /// Test seam invoked after each successful FK parent-protection lock
1694    /// acquisition inside constraint validation, so tests can rendezvous two
1695    /// committing transactions into a deterministic wait-for cycle. Behind an
1696    /// `Arc` so firing never holds the slot's mutex — concurrent commits (and
1697    /// a hook that itself parks) must not serialize on it.
1698    #[doc(hidden)]
1699    fk_lock_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
1700    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1701    trigger_recursive: AtomicBool,
1702    trigger_max_depth: AtomicU32,
1703    trigger_max_loop_iterations: AtomicU32,
1704    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1705    /// is reconstructed from the WAL by [`Database::change_events_since`].
1706    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1707    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1708    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1709    change_wake: tokio::sync::broadcast::Sender<()>,
1710    /// Final field so every storage resource drops before the exclusive lease.
1711    /// Behind a `Mutex<Option<_>>` so `shutdown()` can release the file lock
1712    /// (spec §10.1, S1A-004 step 7) while handles still reference the core.
1713    _lock: Mutex<Option<ExclusiveDatabaseLease>>,
1714}
1715
1716/// The exclusive public owner of one [`DatabaseCore`] (spec §10.1, S1A-001).
1717///
1718/// `Database::open`/`create*` build exactly one core for a root and reject any
1719/// other core — exclusive or shared — for the same root (spec §2.6). All
1720/// storage state lives on the core; this type adds the owner-bound identity
1721/// context (the cached principal and the shared auth state used by the
1722/// embedded enforcement path). Method calls and field reads transparently
1723/// reach the core through `Deref`, so existing `Database` call sites are
1724/// unchanged.
1725pub struct Database {
1726    core: Arc<DatabaseCore>,
1727    /// The authenticated principal for this owner. `None` on databases
1728    /// opened without credentials (the default — `require_auth = false`),
1729    /// `Some` on credentialed opens. Consulted by every enforcement point
1730    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1731    /// because the access pattern is read-heavy: every `require()` call
1732    /// reads the principal, while writes happen only at open, `enable_auth`,
1733    /// and `refresh_principal`. This matches the engine's existing use of
1734    /// `RwLock` for `catalog` and `tables`.
1735    /// See `docs/15-credential-enforcement.md`.
1736    principal: RwLock<Option<crate::auth::Principal>>,
1737    /// Shared, cloneable handle to the auth state (require_auth flag from the
1738    /// catalog + the principal). Cloned into every mounted `Table` so the
1739    /// Table layer can enforce permissions without holding a reference back
1740    /// to `Database` (which would create a cycle). `AuthState` is already
1741    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1742    auth_state: crate::auth_state::AuthState,
1743    /// `true` when this value is a manager-issued facade over a shared core
1744    /// (Stage 1A): dropping it has no storage side effects, `shutdown()`
1745    /// rejects, and auth-mode transitions are refused so one handle cannot
1746    /// flip the shared core into an enforcement mode other handles cannot
1747    /// observe (fail closed; per-handle enforcement lands with Stage 1D
1748    /// sessions).
1749    shared: bool,
1750}
1751
1752impl std::ops::Deref for Database {
1753    type Target = DatabaseCore;
1754
1755    fn deref(&self) -> &DatabaseCore {
1756        &self.core
1757    }
1758}
1759
1760struct RunPins {
1761    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1762    runs: Vec<(u64, u128)>,
1763}
1764
1765/// RAII proof that one transaction's lock holds release when the scope that
1766/// acquired them exits (S1B-004 step 12). Used by the commit path (covers
1767/// success, validation failure, and poison alike) and by DDL entry points
1768/// holding the Exclusive schema barrier.
1769struct TxnLockGuard<'a> {
1770    locks: &'a crate::locks::LockManager,
1771    txn_id: u64,
1772}
1773
1774impl Drop for TxnLockGuard<'_> {
1775    fn drop(&mut self) {
1776        self.locks.release_all(self.txn_id);
1777    }
1778}
1779
1780struct BackupFilePins {
1781    root: PathBuf,
1782}
1783
1784struct PendingTableDir {
1785    path: PathBuf,
1786    armed: bool,
1787}
1788
1789impl PendingTableDir {
1790    fn new(path: PathBuf) -> Self {
1791        Self { path, armed: true }
1792    }
1793
1794    fn disarm(&mut self) {
1795        self.armed = false;
1796    }
1797}
1798
1799impl Drop for PendingTableDir {
1800    fn drop(&mut self) {
1801        if self.armed {
1802            let _ = std::fs::remove_dir_all(&self.path);
1803        }
1804    }
1805}
1806
1807impl Drop for BackupFilePins {
1808    fn drop(&mut self) {
1809        let _ = std::fs::remove_dir_all(&self.root);
1810    }
1811}
1812
1813impl Drop for RunPins {
1814    fn drop(&mut self) {
1815        let mut pins = self.pins.lock();
1816        for run in &self.runs {
1817            if let Some(count) = pins.get_mut(run) {
1818                *count -= 1;
1819                if *count == 0 {
1820                    pins.remove(run);
1821                }
1822            }
1823        }
1824    }
1825}
1826
1827/// A durable data-change event reconstructed from committed WAL records, or an
1828/// ephemeral SQL `NOTIFY` event when `id` is `None`.
1829#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1830pub struct ChangeEvent {
1831    pub id: Option<String>,
1832    pub channel: String,
1833    pub table_id: Option<u64>,
1834    pub table: String,
1835    pub op: String,
1836    pub epoch: u64,
1837    pub txn_id: Option<u64>,
1838    pub message: Option<String>,
1839    pub data: Option<serde_json::Value>,
1840}
1841
1842#[derive(Debug, Clone)]
1843pub struct CdcBatch {
1844    pub events: Vec<ChangeEvent>,
1845    pub current_epoch: u64,
1846    pub earliest_epoch: Option<u64>,
1847    pub gap: bool,
1848}
1849
1850/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
1851/// (root, epoch, table count, encryption/auth state) without requiring every
1852/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
1853/// The raw field types carry locks, trait objects, and channels that have no
1854/// useful `Debug` output, so a hand-written impl is clearer than peppering
1855/// `#[allow(dead_code)]` skip attributes across two dozen fields.
1856impl std::fmt::Debug for Database {
1857    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1858        let cat = self.catalog.read();
1859        let principal_guard = self.principal.read();
1860        let principal: &str = principal_guard
1861            .as_ref()
1862            .map(|p| p.username.as_str())
1863            .unwrap_or("<none>");
1864        f.debug_struct("Database")
1865            .field("root", &self.root)
1866            .field("db_epoch", &cat.db_epoch)
1867            .field("open_generation", &"sidecar")
1868            .field("tables", &cat.tables.len())
1869            .field("visible_epoch", &self.epoch.visible().0)
1870            .field("encrypted", &self.kek.is_some())
1871            .field("require_auth", &cat.require_auth)
1872            .field("principal", &principal)
1873            .finish()
1874    }
1875}
1876
1877/// Manual `Debug` for `DatabaseCore`, mirroring `Database`'s (see above). The
1878/// core has no cached principal by design (spec §4.6).
1879impl std::fmt::Debug for DatabaseCore {
1880    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1881        let cat = self.catalog.read();
1882        f.debug_struct("DatabaseCore")
1883            .field("root", &self.root)
1884            .field("lifecycle", &self.lifecycle.state())
1885            .field("db_epoch", &cat.db_epoch)
1886            .field("tables", &cat.tables.len())
1887            .field("visible_epoch", &self.epoch.visible().0)
1888            .field("encrypted", &self.kek.is_some())
1889            .field("require_auth", &cat.require_auth)
1890            .finish()
1891    }
1892}
1893
1894impl DatabaseCore {
1895    fn ensure_owner_process(&self) -> Result<()> {
1896        let current_pid = std::process::id();
1897        let owner_pid = self
1898            ._lock
1899            .lock()
1900            .as_ref()
1901            .map(|lease| lease.owner_pid)
1902            .unwrap_or(current_pid);
1903        if current_pid == owner_pid {
1904            Ok(())
1905        } else {
1906            Err(MongrelError::ForkedProcess {
1907                owner_pid,
1908                current_pid,
1909            })
1910        }
1911    }
1912
1913    /// The canonical filesystem root this core was opened at.
1914    pub fn root(&self) -> &Path {
1915        self.durable_root.canonical_path()
1916    }
1917
1918    /// The stable directory-handle identity of this core's root (S1A-003).
1919    pub fn file_identity(&self) -> Result<crate::core::DatabaseFileIdentity> {
1920        crate::core::DatabaseFileIdentity::from_durable_root(&self.durable_root)
1921    }
1922
1923    /// The current lifecycle state (S1A-004).
1924    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
1925        self.lifecycle.state()
1926    }
1927
1928    /// Whether this core currently admits new operations.
1929    pub fn is_open(&self) -> bool {
1930        self.lifecycle.is_open()
1931    }
1932
1933    /// Admit one operation against this core (S1A-004: "every operation holds
1934    /// an `OperationGuard`"). Rejected unless the core is
1935    /// [`crate::core::LifecycleState::Open`].
1936    pub fn operation_guard(self: &Arc<Self>) -> Result<crate::core::OperationGuard> {
1937        self.lifecycle.begin_operation()
1938    }
1939
1940    /// Register this core with the [`crate::manager::DatabaseManager`] that
1941    /// initialized it (S1A-002). Called once, before the first handle is
1942    /// handed out.
1943    pub(crate) fn set_registry(
1944        &self,
1945        registration: crate::manager::CoreRegistration,
1946    ) -> Result<()> {
1947        self.registry
1948            .set(registration)
1949            .map_err(|_| MongrelError::Conflict("database core is already registry-bound".into()))
1950    }
1951
1952    /// Ordered core shutdown (spec §10.1, S1A-004):
1953    ///
1954    /// 1. Transition `Open` → `Draining`.
1955    /// 2. Reject new sessions and writes (new [`Self::operation_guard`]s fail
1956    ///    from this point).
1957    /// 3. Cancel non-commit-critical queries — a no-op in Stage 1A: the
1958    ///    embedded core has no central query registry yet (Stage 1D adds one),
1959    ///    and every in-flight operation is treated as commit-critical and
1960    ///    drained rather than cancelled.
1961    /// 4. Wait for in-flight operations, up to `drain_deadline`. On timeout
1962    ///    the core stays `Draining` and a later call may resume the shutdown.
1963    /// 5. Sync required durable state (group-sync the shared WAL).
1964    /// 6. Stop workers — the Stage 1A core runs no background worker set.
1965    /// 7. Release the file lock (and the process open-registry entries).
1966    /// 8. Mark `Closed`.
1967    ///
1968    /// Repeated calls after `Closed` return `Ok(())`. Dropping one handle has
1969    /// no storage side effects; only this method (or the last `Arc` drop)
1970    /// closes storage.
1971    pub fn shutdown(self: &Arc<Self>, drain_deadline: std::time::Duration) -> Result<()> {
1972        self.ensure_owner_process()?;
1973        let initiated = self.lifecycle.begin_shutdown();
1974        if !initiated {
1975            match self.lifecycle.state() {
1976                crate::core::LifecycleState::Closed => return Ok(()),
1977                // A concurrent or timed-out shutdown left the core draining;
1978                // join it and drive the remaining steps.
1979                crate::core::LifecycleState::Draining => {}
1980                state => {
1981                    return Err(MongrelError::Conflict(format!(
1982                        "database core cannot shut down from lifecycle state {state}"
1983                    )))
1984                }
1985            }
1986        }
1987        if let Some(registration) = self.registry.get() {
1988            registration
1989                .manager
1990                .mark_closing(&registration.identity, self);
1991        }
1992        self.lifecycle.wait_drained(drain_deadline)?;
1993        let sync = self.shared_wal.lock().group_sync().map(|_| ());
1994        self.lifecycle.mark_closing();
1995        let lease = self._lock.lock().take();
1996        drop(lease);
1997        if let Some(registration) = self.registry.get() {
1998            registration
1999                .manager
2000                .entry_closed(&registration.identity, self);
2001        }
2002        self.lifecycle.mark_closed();
2003        sync
2004    }
2005}
2006
2007impl Database {
2008    pub fn open_metrics() -> DatabaseOpenMetrics {
2009        DatabaseOpenMetrics {
2010            lock_waits: DATABASE_OPEN_WAIT_COUNT.load(Ordering::Relaxed),
2011            failures: DATABASE_OPEN_FAILURE_COUNT.load(Ordering::Relaxed),
2012        }
2013    }
2014
2015    /// The storage core this owner references (spec §10.1, S1A-001).
2016    pub fn core(&self) -> Arc<DatabaseCore> {
2017        Arc::clone(&self.core)
2018    }
2019
2020    /// The identity bound to this owner (spec §10.1, S1A-001): a catalog user
2021    /// for credentialed opens, `Credentialless` otherwise.
2022    pub fn identity(&self) -> crate::handle::HandleIdentity {
2023        match self.principal.read().as_ref() {
2024            Some(principal) => crate::handle::HandleIdentity::CatalogUser {
2025                username: principal.username.clone(),
2026                user_id: principal.user_id,
2027                created_version: principal.created_epoch,
2028            },
2029            None => crate::handle::HandleIdentity::Credentialless,
2030        }
2031    }
2032
2033    /// The current lifecycle state of the underlying storage core (S1A-004).
2034    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2035        self.core.lifecycle_state()
2036    }
2037
2038    /// Admit one operation against the storage core (S1A-004). The returned
2039    /// RAII guard releases the operation slot on drop; new operations are
2040    /// rejected once the core leaves [`crate::core::LifecycleState::Open`].
2041    pub fn operation_guard(&self) -> Result<crate::core::OperationGuard> {
2042        self.core.operation_guard()
2043    }
2044
2045    /// Build an owner facade over an existing shared core. Used by
2046    /// [`crate::manager::DatabaseManager`] to back [`crate::handle::DatabaseHandle`]s;
2047    /// exclusive `Database::open*` constructors build their own core instead.
2048    pub(crate) fn from_core(
2049        core: Arc<DatabaseCore>,
2050        principal: Option<crate::auth::Principal>,
2051        shared: bool,
2052    ) -> Self {
2053        let require_auth = core.catalog.read().require_auth;
2054        Self {
2055            core,
2056            principal: RwLock::new(principal.clone()),
2057            auth_state: crate::auth_state::AuthState::new(require_auth, principal),
2058            shared,
2059        }
2060    }
2061
2062    /// Consume this owner and yield the shared core (keeps the core alive).
2063    pub(crate) fn into_core(self) -> Arc<DatabaseCore> {
2064        self.core
2065    }
2066
2067    /// Open an existing plaintext database as the one core backing shared
2068    /// handles (spec §10.1, S1A-002). Recovery, WAL opening, open-generation
2069    /// advancement, and table mounting all happen inside this call — exactly
2070    /// once per core.
2071    pub(crate) fn open_for_shared_core(root: impl AsRef<Path>) -> Result<Self> {
2072        Self::open_inner_with_lock_timeout(
2073            root,
2074            None,
2075            None,
2076            0,
2077            CoreResourceConfig::default(),
2078            OpenModeGate::Normal,
2079        )
2080    }
2081
2082    /// Explicitly close the final shared database owner.
2083    ///
2084    /// On a manager-issued facade (`shared`) this rejects: dropping one handle
2085    /// never closes storage while another handle exists, and shared cores are
2086    /// shut down explicitly via [`crate::handle::DatabaseHandle::shutdown`].
2087    pub fn shutdown(self: Arc<Self>) -> Result<()> {
2088        if self.shared {
2089            return Err(MongrelError::Conflict(
2090                "shared-core facades do not own storage; use DatabaseHandle::shutdown".into(),
2091            ));
2092        }
2093        match Arc::try_unwrap(self) {
2094            Ok(database) => {
2095                database.ensure_owner_process()?;
2096                // Drain + close the exclusively-owned core (S1A-004 steps:
2097                // reject new operations, wait for the drain, sync durable
2098                // state, release the file lock, mark Closed), then drop it.
2099                database.core.shutdown(std::time::Duration::from_secs(30))?;
2100                drop(database);
2101                Ok(())
2102            }
2103            Err(database) => Err(MongrelError::DatabaseBusy {
2104                strong_handles: Arc::strong_count(&database),
2105            }),
2106        }
2107    }
2108
2109    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
2110        if let Ok(canonical) = root.canonicalize() {
2111            let lock_dir = canonical.parent().ok_or_else(|| {
2112                std::io::Error::new(
2113                    std::io::ErrorKind::InvalidInput,
2114                    "database root must have a parent directory",
2115                )
2116            })?;
2117            return Ok((canonical.clone(), lock_dir.to_path_buf()));
2118        }
2119
2120        let absolute = if root.is_absolute() {
2121            root.to_path_buf()
2122        } else {
2123            std::env::current_dir()?.join(root)
2124        };
2125        let mut cursor = absolute.as_path();
2126        let mut suffix = Vec::new();
2127        while !cursor.exists() {
2128            let name = cursor.file_name().ok_or_else(|| {
2129                std::io::Error::new(
2130                    std::io::ErrorKind::NotFound,
2131                    format!("no existing ancestor for database root {}", root.display()),
2132                )
2133            })?;
2134            suffix.push(name.to_os_string());
2135            cursor = cursor.parent().ok_or_else(|| {
2136                std::io::Error::new(
2137                    std::io::ErrorKind::NotFound,
2138                    format!("no existing ancestor for database root {}", root.display()),
2139                )
2140            })?;
2141        }
2142        let lock_dir = cursor.canonicalize()?;
2143        let mut canonical = lock_dir.clone();
2144        for component in suffix.iter().rev() {
2145            canonical.push(component);
2146        }
2147        Ok((canonical, lock_dir))
2148    }
2149
2150    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<ExclusiveDatabaseLease> {
2151        use std::hash::{Hash, Hasher};
2152
2153        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
2154        let reservation =
2155            OpenReservation::acquire(DatabaseOpenKey::IntendedPath(canonical_path.clone()), root)?;
2156
2157        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2158        canonical_path.hash(&mut hasher);
2159        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
2160        let file = std::fs::OpenOptions::new()
2161            .create(true)
2162            .truncate(false)
2163            .write(true)
2164            .open(lock_path)?;
2165        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2166            return Err(MongrelError::DatabaseLocked {
2167                path: root.to_path_buf(),
2168                message: error.to_string(),
2169            });
2170        }
2171        Ok(reservation.into_lease(file, canonical_path))
2172    }
2173
2174    fn acquire_legacy_database_lock(
2175        lock: &mut ExclusiveDatabaseLease,
2176        root: &Path,
2177        timeout_ms: u32,
2178    ) -> Result<()> {
2179        let durable_root = lock
2180            .durable_root
2181            .as_ref()
2182            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
2183        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
2184        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2185            return Err(MongrelError::DatabaseLocked {
2186                path: root.to_path_buf(),
2187                message: error.to_string(),
2188            });
2189        }
2190        lock.legacy_file = Some(file);
2191        Ok(())
2192    }
2193
2194    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
2195        if path.exists() {
2196            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
2197        }
2198        let mut ancestor = path;
2199        while !ancestor.exists() {
2200            ancestor = ancestor.parent().ok_or_else(|| {
2201                MongrelError::NotFound(format!(
2202                    "no existing ancestor for database root {}",
2203                    path.display()
2204                ))
2205            })?;
2206        }
2207        let relative = path.strip_prefix(ancestor).map_err(|error| {
2208            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
2209        })?;
2210        crate::durable_file::DurableRoot::open(ancestor)?
2211            .create_directory_all_pinned(relative)
2212            .map_err(Into::into)
2213    }
2214
2215    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2216        let requested_root = root.as_ref();
2217        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
2218        let root = lock.canonical_path.clone();
2219        Self::reject_existing_database(&root)?;
2220        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
2221        if durable_root.canonical_path() != lock.canonical_path {
2222            return Err(MongrelError::Conflict(
2223                "database root changed while it was being created".into(),
2224            ));
2225        }
2226        lock.claim_root_identity(&durable_root)?;
2227        durable_root.create_directory_all(META_DIR)?;
2228        lock.durable_root = Some(durable_root);
2229        let io_root = lock
2230            .durable_root
2231            .as_ref()
2232            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2233            .io_path()?;
2234        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
2235        Self::reject_existing_database(&io_root)?;
2236        Ok((io_root, lock))
2237    }
2238
2239    fn begin_open(
2240        root: impl AsRef<Path>,
2241        lock_timeout_ms: u32,
2242    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2243        let root = root.as_ref();
2244        let canonical_root = root.canonicalize().map_err(|error| {
2245            if error.kind() == std::io::ErrorKind::NotFound {
2246                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
2247            } else {
2248                error.into()
2249            }
2250        })?;
2251        let durable_root = crate::durable_file::DurableRoot::open(&canonical_root)?;
2252        Self::begin_open_durable(durable_root, lock_timeout_ms)
2253    }
2254
2255    fn begin_open_durable(
2256        durable_root: crate::durable_file::DurableRoot,
2257        lock_timeout_ms: u32,
2258    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2259        let io_root = durable_root.io_path()?;
2260        let current_root = io_root.canonicalize()?;
2261        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
2262        lock.claim_root_identity(&durable_root)?;
2263        lock.durable_root = Some(Arc::new(durable_root));
2264        let io_root = lock
2265            .durable_root
2266            .as_ref()
2267            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2268            .io_path()?;
2269        if lock
2270            .durable_root
2271            .as_ref()
2272            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2273            .open_directory(META_DIR)
2274            .is_err()
2275        {
2276            return Err(MongrelError::NotFound(format!(
2277                "no database metadata found at {:?}",
2278                current_root
2279            )));
2280        }
2281        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
2282        Ok((io_root, lock))
2283    }
2284
2285    /// Create a fresh plaintext database at `root`.
2286    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
2287        let (root, lock) = Self::begin_create(root)?;
2288        Self::create_inner(
2289            root,
2290            None,
2291            lock,
2292            crate::storage_mode::StorageMode::Standalone,
2293        )
2294    }
2295
2296    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
2297    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
2298    #[cfg(feature = "encryption")]
2299    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2300        let (root, lock) = Self::begin_create(root)?;
2301        let salt = crate::encryption::random_salt()?;
2302        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2303        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2304        Self::create_inner(
2305            root,
2306            Some(kek),
2307            lock,
2308            crate::storage_mode::StorageMode::Standalone,
2309        )
2310    }
2311
2312    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
2313    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
2314    #[cfg(feature = "encryption")]
2315    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2316        let (root, lock) = Self::begin_create(root)?;
2317        let salt = crate::encryption::random_salt()?;
2318        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2319        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2320        Self::create_inner(
2321            root,
2322            Some(kek),
2323            lock,
2324            crate::storage_mode::StorageMode::Standalone,
2325        )
2326    }
2327
2328    /// Create a fresh plaintext database owned by the cluster node runtime
2329    /// (spec section 5.3): the durable marker records
2330    /// [`crate::storage_mode::StorageMode::ClusterReplica`], so normal
2331    /// `Database::open*` paths reject the directory and only
2332    /// [`Database::open_cluster_replica`] (or the read-only offline validator)
2333    /// can open it afterwards. The returned core rejects user writes: every
2334    /// mutation arrives through the replicated apply path (Stage 2E).
2335    pub fn create_cluster_replica(
2336        root: impl AsRef<Path>,
2337        cluster_id: mongreldb_types::ids::ClusterId,
2338        node_id: mongreldb_types::ids::NodeId,
2339        database_id: mongreldb_types::ids::DatabaseId,
2340    ) -> Result<Self> {
2341        let (root, lock) = Self::begin_create(root)?;
2342        Self::create_inner(
2343            root,
2344            None,
2345            lock,
2346            crate::storage_mode::StorageMode::ClusterReplica {
2347                cluster_id,
2348                node_id,
2349                database_id,
2350            },
2351        )
2352    }
2353
2354    /// Open a cluster-replica database as the cluster node runtime (spec
2355    /// section 5.3). Fails closed unless the durable marker exists and exactly
2356    /// matches `expected` (a `ClusterReplica` identity): opening a replica of
2357    /// the wrong cluster or database would corrupt both. The opened core
2358    /// rejects user writes with [`MongrelError::ReadOnlyReplica`]; mutations
2359    /// arrive through the replicated apply path (Stage 2E).
2360    pub fn open_cluster_replica(
2361        root: impl AsRef<Path>,
2362        expected: &crate::storage_mode::StorageMode,
2363    ) -> Result<Self> {
2364        let Some((cluster_id, node_id, database_id)) = expected.cluster_identity() else {
2365            return Err(MongrelError::InvalidArgument(format!(
2366                "open_cluster_replica requires a ClusterReplica identity, got {expected:?}"
2367            )));
2368        };
2369        let (root, lock) = Self::begin_open(root, 0)?;
2370        Self::open_inner_locked(
2371            root,
2372            None,
2373            lock,
2374            CoreResourceConfig::default(),
2375            OpenModeGate::ClusterRuntime {
2376                cluster_id,
2377                node_id,
2378                database_id,
2379            },
2380        )
2381    }
2382
2383    fn create_inner(
2384        root: PathBuf,
2385        kek: Option<Arc<crate::encryption::Kek>>,
2386        lock: ExclusiveDatabaseLease,
2387        storage_mode: crate::storage_mode::StorageMode,
2388    ) -> Result<Self> {
2389        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2390        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2391        let cat = Catalog::empty();
2392        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2393        Self::finish_open(
2394            root,
2395            cat,
2396            kek,
2397            meta_dek,
2398            false,
2399            None,
2400            None,
2401            None,
2402            lock,
2403            CoreResourceConfig::default(),
2404            OpenModeGate::Create(storage_mode),
2405        )
2406    }
2407
2408    /// Open an existing plaintext database.
2409    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
2410        Self::open_inner(root, None, None)
2411    }
2412
2413    /// Open an existing encrypted database with a passphrase.
2414    #[cfg(feature = "encryption")]
2415    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2416        let (root, lock) = Self::begin_open(root, 0)?;
2417        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2418            MongrelError::Other("database root descriptor was not pinned".into())
2419        })?)?;
2420        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2421        Self::open_inner_locked(
2422            root,
2423            Some(kek),
2424            lock,
2425            CoreResourceConfig::default(),
2426            OpenModeGate::Normal,
2427        )
2428    }
2429
2430    /// Open an existing encrypted database with a configurable cross-process
2431    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
2432    #[cfg(feature = "encryption")]
2433    pub fn open_encrypted_with_options(
2434        root: impl AsRef<Path>,
2435        passphrase: &str,
2436        options: OpenOptions,
2437    ) -> Result<Self> {
2438        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2439        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2440            MongrelError::Other("database root descriptor was not pinned".into())
2441        })?)?;
2442        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2443        let gate = if options.offline_validation {
2444            OpenModeGate::OfflineValidation
2445        } else {
2446            OpenModeGate::Normal
2447        };
2448        Self::open_inner_locked(
2449            root,
2450            Some(kek),
2451            lock,
2452            CoreResourceConfig::from_options(&options)?,
2453            gate,
2454        )
2455    }
2456
2457    /// Open an existing encrypted database using a raw high-entropy key.
2458    #[cfg(feature = "encryption")]
2459    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2460        let (root, lock) = Self::begin_open(root, 0)?;
2461        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2462            MongrelError::Other("database root descriptor was not pinned".into())
2463        })?)?;
2464        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2465        Self::open_inner_locked(
2466            root,
2467            Some(kek),
2468            lock,
2469            CoreResourceConfig::default(),
2470            OpenModeGate::Normal,
2471        )
2472    }
2473
2474    /// Open an existing plaintext database that has `require_auth = true`,
2475    /// verifying the supplied credentials up front and caching the resolved
2476    /// [`Principal`] on the returned handle. Every subsequent operation will
2477    /// be checked against that principal.
2478    ///
2479    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
2480    /// `require_auth` enabled — callers must pick the matching constructor for
2481    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
2482    /// bad username/password.
2483    ///
2484    /// See `docs/15-credential-enforcement.md`.
2485    pub fn open_with_credentials(
2486        root: impl AsRef<Path>,
2487        username: &str,
2488        password: &str,
2489    ) -> Result<Self> {
2490        Self::open_inner_with_credentials(root, None, username, password)
2491    }
2492
2493    /// Open with credentials and a configurable cross-process lock timeout.
2494    /// Mirrors [`open_with_options`](Self::open_with_options) for the
2495    /// credentialed path.
2496    pub fn open_with_credentials_and_options(
2497        root: impl AsRef<Path>,
2498        username: &str,
2499        password: &str,
2500        options: OpenOptions,
2501    ) -> Result<Self> {
2502        let gate = if options.offline_validation {
2503            OpenModeGate::OfflineValidation
2504        } else {
2505            OpenModeGate::Normal
2506        };
2507        Self::open_inner_with_credentials_and_lock_timeout(
2508            root,
2509            None,
2510            username,
2511            password,
2512            options.lock_timeout_ms,
2513            CoreResourceConfig::from_options(&options)?,
2514            gate,
2515        )
2516    }
2517
2518    /// Open an existing encrypted database that has `require_auth = true`,
2519    /// combining the encryption passphrase flow with credential verification.
2520    #[cfg(feature = "encryption")]
2521    pub fn open_encrypted_with_credentials(
2522        root: impl AsRef<Path>,
2523        passphrase: &str,
2524        username: &str,
2525        password: &str,
2526    ) -> Result<Self> {
2527        let (root, lock) = Self::begin_open(root, 0)?;
2528        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2529            MongrelError::Other("database root descriptor was not pinned".into())
2530        })?)?;
2531        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2532        Self::open_inner_with_credentials_locked(
2533            root,
2534            Some(kek),
2535            username,
2536            password,
2537            lock,
2538            CoreResourceConfig::default(),
2539            OpenModeGate::Normal,
2540        )
2541    }
2542
2543    /// Open an encrypted + credentialed database with a configurable
2544    /// cross-process lock timeout. Mirrors
2545    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
2546    #[cfg(feature = "encryption")]
2547    pub fn open_encrypted_with_credentials_and_options(
2548        root: impl AsRef<Path>,
2549        passphrase: &str,
2550        username: &str,
2551        password: &str,
2552        options: OpenOptions,
2553    ) -> Result<Self> {
2554        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2555        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2556            MongrelError::Other("database root descriptor was not pinned".into())
2557        })?)?;
2558        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2559        let gate = if options.offline_validation {
2560            OpenModeGate::OfflineValidation
2561        } else {
2562            OpenModeGate::Normal
2563        };
2564        Self::open_inner_with_credentials_locked(
2565            root,
2566            Some(kek),
2567            username,
2568            password,
2569            lock,
2570            CoreResourceConfig::from_options(&options)?,
2571            gate,
2572        )
2573    }
2574
2575    /// Open an existing database with non-default [`OpenOptions`].
2576    ///
2577    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
2578    /// rather than the fail-fast default. The other open constructors keep
2579    /// their previous defaults; use their `*_with_options` variants when they
2580    /// need the same timeout behavior.
2581    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
2582        // No encryption, no auth; encrypted and credentialed paths have their
2583        // own `*_with_options` constructors.
2584        let gate = if options.offline_validation {
2585            OpenModeGate::OfflineValidation
2586        } else {
2587            OpenModeGate::Normal
2588        };
2589        Self::open_inner_with_lock_timeout(
2590            root,
2591            None,
2592            None,
2593            options.lock_timeout_ms,
2594            CoreResourceConfig::from_options(&options)?,
2595            gate,
2596        )
2597    }
2598
2599    fn open_inner_with_lock_timeout(
2600        root: impl AsRef<Path>,
2601        kek: Option<Arc<crate::encryption::Kek>>,
2602        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2603        lock_timeout_ms: u32,
2604        resources: CoreResourceConfig,
2605        mode_gate: OpenModeGate,
2606    ) -> Result<Self> {
2607        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
2608        Self::open_inner_locked(root, kek, lock, resources, mode_gate)
2609    }
2610
2611    fn open_inner_locked(
2612        root: PathBuf,
2613        kek: Option<Arc<crate::encryption::Kek>>,
2614        lock: ExclusiveDatabaseLease,
2615        resources: CoreResourceConfig,
2616        mode_gate: OpenModeGate,
2617    ) -> Result<Self> {
2618        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2619        let mut cat = catalog::read_durable(
2620            lock.durable_root.as_deref().ok_or_else(|| {
2621                MongrelError::Other("database root descriptor was not pinned".into())
2622            })?,
2623            meta_dek.as_ref(),
2624        )?
2625        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2626        let recovery_checkpoint = cat.clone();
2627
2628        // CATALOG is only a checkpoint. Authentication must use the
2629        // authoritative catalog after committed WAL DDL/security replay.
2630        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2631        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2632            lock.durable_root.as_deref().ok_or_else(|| {
2633                MongrelError::Other("database root descriptor was not pinned".into())
2634            })?,
2635            wal_dek.as_ref(),
2636        )?;
2637        recover_ddl_from_records(
2638            &root,
2639            Some(lock.durable_root.as_deref().ok_or_else(|| {
2640                MongrelError::Other("database root descriptor was not pinned".into())
2641            })?),
2642            &mut cat,
2643            meta_dek.as_ref(),
2644            false,
2645            None,
2646            &recovery_records,
2647        )?;
2648        Self::finish_open(
2649            root,
2650            cat,
2651            kek,
2652            meta_dek,
2653            true,
2654            Some(recovery_checkpoint),
2655            Some(recovery_records),
2656            None,
2657            lock,
2658            resources,
2659            mode_gate,
2660        )
2661    }
2662
2663    /// Shared credentialed-open inner: read the catalog, verify the database
2664    /// requires auth, verify the password, resolve the principal, and pass
2665    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
2666    /// problem where `finish_open`'s fail-closed check (`require_auth &&
2667    /// principal.is_none()`) would fire before a post-open `authenticate()`
2668    /// could supply the principal.
2669    fn open_inner_with_credentials(
2670        root: impl AsRef<Path>,
2671        kek: Option<Arc<crate::encryption::Kek>>,
2672        username: &str,
2673        password: &str,
2674    ) -> Result<Self> {
2675        Self::open_inner_with_credentials_and_lock_timeout(
2676            root,
2677            kek,
2678            username,
2679            password,
2680            0,
2681            CoreResourceConfig::default(),
2682            OpenModeGate::Normal,
2683        )
2684    }
2685
2686    /// Credentialed-open with an explicit cross-process lock timeout. The
2687    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
2688    /// historical fail-fast behavior via the wrapper above.
2689    #[allow(clippy::too_many_arguments)]
2690    fn open_inner_with_credentials_and_lock_timeout(
2691        root: impl AsRef<Path>,
2692        kek: Option<Arc<crate::encryption::Kek>>,
2693        username: &str,
2694        password: &str,
2695        lock_timeout_ms: u32,
2696        resources: CoreResourceConfig,
2697        mode_gate: OpenModeGate,
2698    ) -> Result<Self> {
2699        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
2700        Self::open_inner_with_credentials_locked(
2701            root, kek, username, password, lock, resources, mode_gate,
2702        )
2703    }
2704
2705    #[allow(clippy::too_many_arguments)]
2706    fn open_inner_with_credentials_locked(
2707        root: PathBuf,
2708        kek: Option<Arc<crate::encryption::Kek>>,
2709        username: &str,
2710        password: &str,
2711        lock: ExclusiveDatabaseLease,
2712        resources: CoreResourceConfig,
2713        mode_gate: OpenModeGate,
2714    ) -> Result<Self> {
2715        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2716        let mut cat = catalog::read_durable(
2717            lock.durable_root.as_deref().ok_or_else(|| {
2718                MongrelError::Other("database root descriptor was not pinned".into())
2719            })?,
2720            meta_dek.as_ref(),
2721        )?
2722        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2723        let recovery_checkpoint = cat.clone();
2724
2725        // Never verify against a stale checkpoint. A committed password,
2726        // user, role, or auth-mode change in WAL is authoritative.
2727        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2728        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2729            lock.durable_root.as_deref().ok_or_else(|| {
2730                MongrelError::Other("database root descriptor was not pinned".into())
2731            })?,
2732            wal_dek.as_ref(),
2733        )?;
2734        recover_ddl_from_records(
2735            &root,
2736            Some(lock.durable_root.as_deref().ok_or_else(|| {
2737                MongrelError::Other("database root descriptor was not pinned".into())
2738            })?),
2739            &mut cat,
2740            meta_dek.as_ref(),
2741            false,
2742            None,
2743            &recovery_records,
2744        )?;
2745
2746        // Fail early if the database is not in require_auth mode — the caller
2747        // picked the wrong constructor.
2748        if !cat.require_auth {
2749            return Err(MongrelError::AuthNotRequired);
2750        }
2751
2752        // Verify credentials against the on-disk catalog before constructing
2753        // the full Database handle. This reads users/hashes directly from the
2754        // loaded catalog rather than going through the Database::verify_user
2755        // method (which requires a constructed Database).
2756        let user = cat
2757            .users
2758            .iter()
2759            .find(|u| u.username == username)
2760            .filter(|u| !u.password_hash.is_empty())
2761            .ok_or_else(|| MongrelError::InvalidCredentials {
2762                username: username.to_string(),
2763            })?;
2764        let password_ok = crate::auth::verify_password(password, &user.password_hash)
2765            .map_err(MongrelError::Other)?;
2766        if !password_ok {
2767            return Err(MongrelError::InvalidCredentials {
2768                username: username.to_string(),
2769            });
2770        }
2771
2772        // Resolve the principal from the catalog (roles + permissions).
2773        let principal =
2774            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
2775                MongrelError::InvalidCredentials {
2776                    username: username.to_string(),
2777                }
2778            })?;
2779
2780        Self::finish_open(
2781            root,
2782            cat,
2783            kek,
2784            meta_dek,
2785            true,
2786            Some(recovery_checkpoint),
2787            Some(recovery_records),
2788            Some(principal),
2789            lock,
2790            resources,
2791            mode_gate,
2792        )
2793    }
2794
2795    /// Create a fresh plaintext database with `require_auth = true` and a
2796    /// single admin user. The returned handle is already authenticated as
2797    /// that admin — every subsequent operation is checked against the admin
2798    /// principal (which bypasses all permission checks via `is_admin`).
2799    ///
2800    /// This is the bootstrap path: there is no window where the database
2801    /// requires auth but has no users.
2802    ///
2803    /// See `docs/15-credential-enforcement.md`.
2804    pub fn create_with_credentials(
2805        root: impl AsRef<Path>,
2806        admin_username: &str,
2807        admin_password: &str,
2808    ) -> Result<Self> {
2809        let (root, lock) = Self::begin_create(root)?;
2810        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
2811    }
2812
2813    /// Create a fresh encrypted database with `require_auth = true` and a
2814    /// single admin user. Composes encryption-at-rest with credential
2815    /// enforcement.
2816    #[cfg(feature = "encryption")]
2817    pub fn create_encrypted_with_credentials(
2818        root: impl AsRef<Path>,
2819        passphrase: &str,
2820        admin_username: &str,
2821        admin_password: &str,
2822    ) -> Result<Self> {
2823        let (root, lock) = Self::begin_create(root)?;
2824        let salt = crate::encryption::random_salt()?;
2825        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2826        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2827        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
2828    }
2829
2830    fn create_inner_with_credentials(
2831        root: PathBuf,
2832        kek: Option<Arc<crate::encryption::Kek>>,
2833        admin_username: &str,
2834        admin_password: &str,
2835        lock: ExclusiveDatabaseLease,
2836    ) -> Result<Self> {
2837        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2838        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2839
2840        // Build the initial catalog with require_auth = true and one admin user.
2841        let password_hash =
2842            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2843        let mut cat = Catalog::empty();
2844        cat.require_auth = true;
2845        cat.next_user_id = 2;
2846        cat.users.push(crate::auth::UserEntry {
2847            id: 1,
2848            username: admin_username.to_string(),
2849            password_hash,
2850            roles: Vec::new(),
2851            is_admin: true,
2852            created_epoch: 0,
2853        });
2854        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2855
2856        // The handle is constructed already authenticated as the admin user
2857        // it just created — no separate verify step needed.
2858        let admin_principal = crate::auth::Principal {
2859            user_id: 1,
2860            created_epoch: 0,
2861            username: admin_username.to_string(),
2862            is_admin: true,
2863            roles: Vec::new(),
2864            permissions: Vec::new(),
2865        };
2866        Self::finish_open(
2867            root,
2868            cat,
2869            kek,
2870            meta_dek,
2871            false,
2872            None,
2873            None,
2874            Some(admin_principal),
2875            lock,
2876            CoreResourceConfig::default(),
2877            OpenModeGate::Create(crate::storage_mode::StorageMode::Standalone),
2878        )
2879    }
2880
2881    fn reject_existing_database(root: &Path) -> Result<()> {
2882        // Refuse to overwrite an existing database. If CATALOG exists, the
2883        // directory already contains a real database; replacing it destroys data.
2884        if root.join(catalog::CATALOG_FILENAME).exists() {
2885            return Err(MongrelError::InvalidArgument(format!(
2886                "database already exists at {}; use Database::open() to open it, \
2887                 or remove the directory first",
2888                root.display()
2889            )));
2890        }
2891        Ok(())
2892    }
2893
2894    fn open_inner(
2895        root: impl AsRef<Path>,
2896        kek: Option<Arc<crate::encryption::Kek>>,
2897        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2898    ) -> Result<Self> {
2899        Self::open_inner_with_lock_timeout(
2900            root,
2901            kek,
2902            None,
2903            0,
2904            CoreResourceConfig::default(),
2905            OpenModeGate::Normal,
2906        )
2907    }
2908
2909    /// Open an existing plaintext database through the special
2910    /// offline-validation API (spec section 5.3): any storage mode, read-only.
2911    pub(crate) fn open_offline_validation(root: impl AsRef<Path>) -> Result<Self> {
2912        Self::open_inner_with_lock_timeout(
2913            root,
2914            None,
2915            None,
2916            0,
2917            CoreResourceConfig::default(),
2918            OpenModeGate::OfflineValidation,
2919        )
2920    }
2921
2922    /// Internal recovery open for a staging directory explicitly marked as a
2923    /// read-only replica. It bypasses user authentication only so PITR can
2924    /// replay auth-mode and password transitions; it is not public API.
2925    pub(crate) fn open_replica_recovery_durable(
2926        root: &crate::durable_file::DurableRoot,
2927    ) -> Result<Self> {
2928        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2929        Self::open_replica_recovery_inner(root, None, lock)
2930    }
2931
2932    #[cfg(feature = "encryption")]
2933    pub(crate) fn open_encrypted_replica_recovery_durable(
2934        root: &crate::durable_file::DurableRoot,
2935        passphrase: &str,
2936    ) -> Result<Self> {
2937        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2938        let salt = read_encryption_salt(root)?;
2939        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2940        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
2941    }
2942
2943    fn open_replica_recovery_inner(
2944        root: PathBuf,
2945        kek: Option<Arc<crate::encryption::Kek>>,
2946        lock: ExclusiveDatabaseLease,
2947    ) -> Result<Self> {
2948        if !root.join(META_DIR).join("replica").is_file() {
2949            return Err(MongrelError::InvalidArgument(
2950                "recovery auth bypass requires a marked replica staging directory".into(),
2951            ));
2952        }
2953        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2954        let mut cat = catalog::read_durable(
2955            lock.durable_root.as_deref().ok_or_else(|| {
2956                MongrelError::Other("database root descriptor was not pinned".into())
2957            })?,
2958            meta_dek.as_ref(),
2959        )?
2960        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2961        let recovery_checkpoint = cat.clone();
2962        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2963        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2964            lock.durable_root.as_deref().ok_or_else(|| {
2965                MongrelError::Other("database root descriptor was not pinned".into())
2966            })?,
2967            wal_dek.as_ref(),
2968        )?;
2969        recover_ddl_from_records(
2970            &root,
2971            Some(lock.durable_root.as_deref().ok_or_else(|| {
2972                MongrelError::Other("database root descriptor was not pinned".into())
2973            })?),
2974            &mut cat,
2975            meta_dek.as_ref(),
2976            false,
2977            None,
2978            &recovery_records,
2979        )?;
2980        let principal = if cat.require_auth {
2981            cat.users
2982                .iter()
2983                .find(|user| user.is_admin)
2984                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
2985                .ok_or_else(|| {
2986                    MongrelError::Schema(
2987                        "authenticated replica catalog has no recoverable admin".into(),
2988                    )
2989                })?
2990                .into()
2991        } else {
2992            None
2993        };
2994        Self::finish_open(
2995            root,
2996            cat,
2997            kek,
2998            meta_dek,
2999            true,
3000            Some(recovery_checkpoint),
3001            Some(recovery_records),
3002            principal,
3003            lock,
3004            CoreResourceConfig::default(),
3005            OpenModeGate::Normal,
3006        )
3007    }
3008
3009    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
3010    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
3011    ///
3012    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
3013    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
3014    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
3015    ///
3016    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
3017    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
3018    /// caller never blocks past its budget even at the tail of a busy lock
3019    /// holder's lock-window.
3020    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
3021        use fs2::FileExt;
3022        if timeout_ms == 0 {
3023            return f.try_lock_exclusive();
3024        }
3025        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
3026        let deadline =
3027            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
3028        let mut next_sleep = std::time::Duration::from_millis(1);
3029        let mut recorded_wait = false;
3030        loop {
3031            match f.try_lock_exclusive() {
3032                Ok(()) => return Ok(()),
3033                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
3034                    if !recorded_wait {
3035                        DATABASE_OPEN_WAIT_COUNT.fetch_add(1, Ordering::Relaxed);
3036                        recorded_wait = true;
3037                    }
3038                    let now = std::time::Instant::now();
3039                    if now >= deadline {
3040                        return Err(std::io::Error::new(
3041                            std::io::ErrorKind::WouldBlock,
3042                            format!("could not acquire database lock within {timeout_ms}ms"),
3043                        ));
3044                    }
3045                    let remaining = deadline - now;
3046                    let sleep = next_sleep.min(remaining);
3047                    std::thread::sleep(sleep);
3048                    // Cap the per-iteration sleep so a single back-off step
3049                    // never overshoots the remaining budget.
3050                    next_sleep = next_sleep
3051                        .saturating_mul(10)
3052                        .min(std::time::Duration::from_millis(50));
3053                }
3054                Err(e) => return Err(e),
3055            }
3056        }
3057    }
3058
3059    #[allow(clippy::too_many_arguments)]
3060    fn finish_open(
3061        root: PathBuf,
3062        cat: Catalog,
3063        kek: Option<Arc<crate::encryption::Kek>>,
3064        meta_dek: Option<[u8; META_DEK_LEN]>,
3065        existing: bool,
3066        recovery_checkpoint: Option<Catalog>,
3067        recovery_records: Option<Vec<crate::wal::Record>>,
3068        principal: Option<crate::auth::Principal>,
3069        lock: ExclusiveDatabaseLease,
3070        resources: CoreResourceConfig,
3071        mode_gate: OpenModeGate,
3072    ) -> Result<Self> {
3073        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
3074            MongrelError::Other("database root descriptor was not pinned".into())
3075        })?);
3076        // Storage-mode gate (spec section 5.3, Stage 2E): read the durable
3077        // marker before any recovery side effect and fail closed on corrupt
3078        // or forbidden modes. Legacy databases have no marker and are treated
3079        // as Standalone (backfilled below, after recovery succeeds).
3080        let storage_mode = if existing {
3081            crate::storage_mode::read(&durable_root)?
3082        } else {
3083            None
3084        };
3085        match (&mode_gate, &storage_mode) {
3086            (OpenModeGate::Create(_), _) => {}
3087            (OpenModeGate::Normal, mode) => crate::storage_mode::check_open(mode.as_ref(), false)?,
3088            (OpenModeGate::OfflineValidation, mode) => {
3089                crate::storage_mode::check_open(mode.as_ref(), true)?
3090            }
3091            (
3092                OpenModeGate::ClusterRuntime {
3093                    cluster_id,
3094                    node_id,
3095                    database_id,
3096                },
3097                Some(actual),
3098            ) => {
3099                let expected = crate::storage_mode::StorageMode::ClusterReplica {
3100                    cluster_id: *cluster_id,
3101                    node_id: *node_id,
3102                    database_id: *database_id,
3103                };
3104                if *actual != expected {
3105                    return Err(
3106                        crate::storage_mode::StorageModeError::IdentityMismatch(format!(
3107                            "expected {expected:?}, marker holds {actual:?}"
3108                        ))
3109                        .into(),
3110                    );
3111                }
3112            }
3113            (OpenModeGate::ClusterRuntime { .. }, None) => {
3114                return Err(crate::storage_mode::StorageModeError::IdentityMismatch(
3115                    "expected a ClusterReplica marker; directory has none".into(),
3116                )
3117                .into());
3118            }
3119        }
3120        let read_only = match &mode_gate {
3121            OpenModeGate::OfflineValidation | OpenModeGate::ClusterRuntime { .. } => true,
3122            // A freshly created cluster replica rejects user writes from the
3123            // start: mutations arrive through the replicated apply path only.
3124            OpenModeGate::Create(mode) => mode.cluster_identity().is_some(),
3125            OpenModeGate::Normal => false,
3126        } || if existing {
3127            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
3128                Ok(_) => true,
3129                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
3130                Err(error) => return Err(error.into()),
3131            }
3132        } else {
3133            false
3134        };
3135        let recovered_catalog = cat;
3136        let mut cat = recovered_catalog.clone();
3137        let abandoned = if existing && !read_only {
3138            let abandoned = cat
3139                .tables
3140                .iter()
3141                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
3142                .map(|entry| entry.table_id)
3143                .collect::<Vec<_>>();
3144            for entry in &mut cat.tables {
3145                if abandoned.contains(&entry.table_id) {
3146                    entry.state = TableState::Dropped {
3147                        at_epoch: cat.db_epoch,
3148                    };
3149                }
3150            }
3151            abandoned
3152        } else {
3153            Vec::new()
3154        };
3155        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3156        let recovery_records = match (existing, recovery_records) {
3157            (true, Some(records)) => records,
3158            (true, None) => {
3159                return Err(MongrelError::Other(
3160                    "existing open has no validated WAL recovery plan".into(),
3161                ))
3162            }
3163            (false, _) => Vec::new(),
3164        };
3165        let (history_epochs, history_start) =
3166            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
3167        let open_generation = if existing {
3168            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
3169                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3170            })?;
3171            let recovered_table_ids = cat
3172                .tables
3173                .iter()
3174                .filter(|entry| {
3175                    checkpoint
3176                        .tables
3177                        .iter()
3178                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
3179                })
3180                .map(|entry| entry.table_id)
3181                .collect::<HashSet<_>>();
3182            let reconciled_table_ids = cat
3183                .tables
3184                .iter()
3185                .filter(|entry| {
3186                    checkpoint
3187                        .tables
3188                        .iter()
3189                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
3190                        .is_some_and(|checkpoint| {
3191                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
3192                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
3193                        })
3194                })
3195                .map(|entry| entry.table_id)
3196                .collect::<HashSet<_>>();
3197            validate_shared_wal_recovery_plan(
3198                &durable_root,
3199                &cat,
3200                &recovered_table_ids,
3201                &reconciled_table_ids,
3202                meta_dek.as_ref(),
3203                kek.clone(),
3204                &recovery_records,
3205            )?;
3206            let retained_generation = recovery_records
3207                .iter()
3208                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
3209                .map(|record| record.txn_id >> 32)
3210                .max()
3211                .unwrap_or(0);
3212            let head_generation =
3213                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
3214            let durable_floor = match head_generation {
3215                Some(head) if retained_generation > head => {
3216                    return Err(MongrelError::CorruptWal {
3217                        offset: retained_generation,
3218                        reason: format!(
3219                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
3220                        ),
3221                    })
3222                }
3223                Some(head) => head,
3224                None => retained_generation,
3225            };
3226            let stored = catalog::read_generation(&durable_root)?;
3227            if stored.is_some_and(|generation| generation < durable_floor) {
3228                return Err(MongrelError::Other(format!(
3229                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
3230                )));
3231            }
3232            let bumped = stored
3233                .unwrap_or(durable_floor)
3234                .max(durable_floor)
3235                .checked_add(1)
3236                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
3237            if bumped > u32::MAX as u64 {
3238                return Err(MongrelError::Full(
3239                    "open-generation namespace exhausted".into(),
3240                ));
3241            }
3242            bumped
3243        } else {
3244            0
3245        };
3246        let principal = if cat.require_auth {
3247            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3248            Some(
3249                Self::resolve_bound_principal_from_catalog(&cat, supplied)
3250                    .ok_or(MongrelError::AuthRequired)?,
3251            )
3252        } else {
3253            principal
3254        };
3255        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
3256        if existing {
3257            for entry in &cat.tables {
3258                if !matches!(entry.state, TableState::Live) {
3259                    continue;
3260                }
3261                match durable_root
3262                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
3263                {
3264                    Ok(root) => {
3265                        table_roots.insert(entry.table_id, Arc::new(root));
3266                    }
3267                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3268                    Err(error) => return Err(error.into()),
3269                }
3270            }
3271        }
3272
3273        // No database-tree mutation occurs above this point. DDL, row payloads,
3274        // immutable runs, auth state, retention, and generation state have all
3275        // been validated against the authoritative recovered catalog.
3276        if existing {
3277            let mut applied = recovery_checkpoint.ok_or_else(|| {
3278                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3279            })?;
3280            recover_ddl_from_records(
3281                &root,
3282                Some(&durable_root),
3283                &mut applied,
3284                meta_dek.as_ref(),
3285                true,
3286                Some(&table_roots),
3287                &recovery_records,
3288            )?;
3289            let catalog_value = |catalog: &Catalog| {
3290                serde_json::to_value(catalog)
3291                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
3292            };
3293            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
3294                return Err(MongrelError::CorruptWal {
3295                    offset: 0,
3296                    reason: "validated and applied DDL recovery plans differ".into(),
3297                });
3298            }
3299            if catalog_value(&cat)? != catalog_value(&applied)? {
3300                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3301            }
3302            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
3303            if !read_only {
3304                sweep_unreferenced_table_dirs(&root, &cat)?;
3305            }
3306            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
3307                Ok(()) => {}
3308                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3309                Err(error) => return Err(error.into()),
3310            }
3311        }
3312
3313        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
3314        let snapshots = Arc::new(SnapshotRegistry::new());
3315        snapshots.configure_history(history_epochs, history_start);
3316        // S1E-003: exactly one node-level memory governor per storage core.
3317        // Both caches reserve under it (their live bytes always show up in
3318        // the governor's accounting) and register as reclaimable subsystems
3319        // the governor drives under escalation step 2.
3320        let memory_governor = crate::memory::MemoryGovernor::new(
3321            crate::memory::GovernorConfig::new(resources.memory_budget_bytes),
3322        )
3323        .map_err(|error| MongrelError::InvalidArgument(format!("memory governor: {error}")))?;
3324        // S1E-004: exactly one spill manager per core, rooted at
3325        // `<db-root>/temp/spill`; opening it runs the startup sweep that
3326        // deletes every stale spill file a prior process run left behind.
3327        let spill_manager = crate::spill::SpillManager::open(
3328            &durable_root,
3329            crate::spill::SpillConfig::new(resources.temp_disk_budget_bytes),
3330            meta_dek,
3331        )?;
3332        // S1F-002: exactly one durable job registry per core (sibling `JOBS`
3333        // file). Crash recovery inside `open` parks mid-`Running` jobs as
3334        // `Paused` for an operator-driven resume.
3335        let job_registry = Arc::new(crate::jobs::JobRegistry::open(&root, meta_dek.as_ref())?);
3336        let page_cache = Arc::new(crate::cache::Sharded::new(
3337            crate::cache::CACHE_SHARDS,
3338            || {
3339                crate::cache::PageCache::new(
3340                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3341                )
3342                .with_governor(
3343                    memory_governor.clone(),
3344                    crate::memory::MemoryClass::PageCache,
3345                )
3346            },
3347        ));
3348        memory_governor.register_reclaimable(&page_cache);
3349        let decoded_cache = Arc::new(crate::cache::Sharded::new(
3350            crate::cache::CACHE_SHARDS,
3351            || {
3352                crate::cache::DecodedPageCache::new(
3353                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3354                )
3355                .with_governor(
3356                    memory_governor.clone(),
3357                    crate::memory::MemoryClass::DecodedCache,
3358                )
3359            },
3360        ));
3361        memory_governor.register_reclaimable(&decoded_cache);
3362        let commit_lock = Arc::new(Mutex::new(()));
3363        let shared_wal = Arc::new(Mutex::new(if existing {
3364            crate::wal::SharedWal::open_durable_root_validated(
3365                Arc::clone(&durable_root),
3366                Epoch(cat.db_epoch),
3367                wal_dek.clone(),
3368                Some(&recovery_records),
3369            )?
3370        } else {
3371            crate::wal::SharedWal::create_with_durable_root(
3372                Arc::clone(&durable_root),
3373                Epoch(cat.db_epoch),
3374                wal_dek.clone(),
3375            )?
3376        }));
3377        // Shared write-path state handed to every mounted table so single-table
3378        // `put`/`commit` writes route through the one shared WAL, the one group-
3379        // commit coordinator, and the one poison flag (B1).
3380        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
3381        // S1A-004: the lifecycle is created before the write-path plumbing so
3382        // the group-commit coordinator and every mounted table can poison it on
3383        // an unrecoverable fsync error; `mark_open` happens only once every
3384        // initialization step below has completed.
3385        let lifecycle = Arc::new(crate::core::LifecycleController::new());
3386        let group = Arc::new(
3387            crate::txn::GroupCommit::new(shared_wal.lock().durable_seq())
3388                .with_lifecycle(Arc::clone(&lifecycle)),
3389        );
3390        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
3391        // Final base value is set after the open-generation bump below; tables
3392        // only draw ids once the user issues a write (post-open), so the
3393        // placeholder is never observed.
3394        let txn_ids = Arc::new(Mutex::new(1u64));
3395        let _ = abandoned;
3396        // FND-004 (spec §9.4): the commit-log authority for this database. The
3397        // transaction-id allocator Arc is stable; its base value is re-seeded
3398        // below once the open generation is final. The HLC clock is the node's
3399        // single timestamp authority (spec §4.1, §8.2; ADR-0003): standalone
3400        // mode uses node tiebreaker 0, and the skew bound only engages once
3401        // remote timestamps are observed (Stage 2 replication).
3402        let hlc = Arc::new(mongreldb_types::hlc::HlcClock::new(
3403            0,
3404            std::time::Duration::from_millis(500),
3405        ));
3406        // S1B-005: durable idempotency ledger (sibling `TXN_IDEMPOTENCY` file
3407        // via `DurableRoot::write_atomic`, mirroring `jobs.rs`'s `JOBS`).
3408        let idempotency = crate::txn::IdempotencyLedger::open(Arc::clone(&durable_root), meta_dek)?;
3409        let commit_log = Arc::new(crate::commit_log::StandaloneCommitLog::new(
3410            Arc::clone(&shared_wal),
3411            Arc::clone(&group),
3412            Arc::clone(&epoch),
3413            Arc::clone(&commit_lock),
3414            Arc::clone(&txn_ids),
3415            root.clone(),
3416            wal_dek.clone(),
3417            Arc::clone(&hlc),
3418        ));
3419
3420        // Build the shared auth state early — it's cloned into every mounted
3421        // Table's SharedCtx so the Table layer can enforce permissions without
3422        // a reference back to Database. The `require_auth` flag is mirrored
3423        // from the catalog; `enable_auth` / `refresh_principal` update it live.
3424        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
3425        let security_coordinator = security_coordinator(&root, cat.security_version);
3426        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
3427            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
3428        ));
3429
3430        // Open every live table against the shared context. Mounted tables have
3431        // no private WAL (B1) — `open_in` just loads the manifest/runs and
3432        // advances the shared epoch authority to its manifest epoch, so the
3433        // final shared watermark is the max across all tables. All of a mounted
3434        // table's committed records are replayed below from the shared WAL.
3435        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
3436        for entry in &cat.tables {
3437            if !matches!(entry.state, TableState::Live) {
3438                continue;
3439            }
3440            let table_root = match table_roots.remove(&entry.table_id) {
3441                Some(root) => root,
3442                None => Arc::new(
3443                    durable_root
3444                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
3445                ),
3446            };
3447            let tdir = table_root.io_path()?;
3448            let ctx = SharedCtx {
3449                root_guard: Some(table_root),
3450                epoch: Arc::clone(&epoch),
3451                page_cache: Arc::clone(&page_cache),
3452                decoded_cache: Arc::clone(&decoded_cache),
3453                snapshots: Arc::clone(&snapshots),
3454                kek: kek.clone(),
3455                commit_lock: Arc::clone(&commit_lock),
3456                shared: Some(crate::engine::SharedWalCtx {
3457                    wal: Arc::clone(&shared_wal),
3458                    group: Arc::clone(&group),
3459                    poisoned: Arc::clone(&poisoned),
3460                    txn_ids: Arc::clone(&txn_ids),
3461                    change_wake: change_wake.clone(),
3462                    lifecycle: Arc::clone(&lifecycle),
3463                }),
3464                table_name: Some(entry.name.clone()),
3465                auth: auth_checker.clone(),
3466                read_only,
3467            };
3468            let t = Table::open_in(&tdir, ctx)?;
3469            tables.insert(entry.table_id, TableHandle::new(t));
3470        }
3471
3472        // Recover transaction writes from the shared WAL (spec §15). This is the
3473        // single durability source for mounted tables: it applies every committed
3474        // record — both single-table `Table::commit` writes and cross-table
3475        // transactions — gated by each table's `flushed_epoch` (records already
3476        // durable in a run are not re-applied).
3477        if existing {
3478            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
3479            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
3480            if read_only {
3481                crate::replication::reconcile_replica_epoch_durable(
3482                    &durable_root,
3483                    epoch.visible().0,
3484                )?;
3485            }
3486            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
3487            // large transactions (spec §8.5, review fix #14).
3488            sweep_pending_txn_dirs(&root, &cat);
3489        }
3490
3491        // Persist only after all semantic recovery and table mounting succeeds.
3492        catalog::write_generation(&durable_root, open_generation)?;
3493        // Storage-mode marker (spec section 5.3): fresh creates record their
3494        // mode; legacy databases (no marker file) are backfilled as Standalone
3495        // on first open. Like the open-generation write above, this is durable
3496        // open bookkeeping, so it also runs for read-only opens.
3497        match &mode_gate {
3498            OpenModeGate::Create(mode) => crate::storage_mode::write(&durable_root, mode)?,
3499            _ if existing && storage_mode.is_none() => {
3500                crate::storage_mode::write(
3501                    &durable_root,
3502                    &crate::storage_mode::StorageMode::Standalone,
3503                )?;
3504            }
3505            _ => {}
3506        }
3507        shared_wal.lock().seal_open_generation(open_generation)?;
3508        crate::replication::replication_identity_durable(&durable_root)?;
3509        let next_txn_id = (open_generation << 32) | 1;
3510        // Seed the shared txn-id allocator now that the generation is final.
3511        *txn_ids.lock() = next_txn_id;
3512        let mut lock = lock;
3513        lock.mark_open()?;
3514
3515        // Initialization is complete: recovery, WAL opening, open-generation
3516        // advancement, and table mounting all happened above, exactly once for
3517        // this core (spec §10.1, S1A-002). The core starts life `Open`.
3518        lifecycle.mark_open();
3519        let core = DatabaseCore {
3520            root,
3521            durable_root,
3522            lifecycle,
3523            registry: std::sync::OnceLock::new(),
3524            read_only,
3525            catalog: RwLock::new(cat),
3526            security_coordinator,
3527            security_catalog_disk_reads: AtomicU64::new(0),
3528            rls_cache: Mutex::new(RlsCache::default()),
3529            epoch,
3530            snapshots,
3531            memory_governor,
3532            page_cache,
3533            decoded_cache,
3534            spill_manager,
3535            resource_groups: crate::resource::ResourceGroupRegistry::with_defaults(),
3536            embedding_providers: crate::embedding::EmbeddingProviderRegistry::new(),
3537            job_registry,
3538            commit_lock,
3539            shared_wal,
3540            next_txn_id: txn_ids,
3541            tables: RwLock::new(tables),
3542            kek,
3543            ddl_lock: Mutex::new(()),
3544            meta_dek,
3545            conflicts: crate::txn::ConflictIndex::new(),
3546            active_txns: crate::txn::ActiveTxns::new(),
3547            lock_manager: Arc::new(crate::locks::LockManager::new()),
3548            poisoned,
3549            group,
3550            commit_log,
3551            hlc,
3552            idempotency,
3553            commit_ts_ledger: Mutex::new(commit_ts_ledger_from_recovery(&recovery_records)),
3554            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
3555            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
3556            replication_barrier: parking_lot::RwLock::new(()),
3557            replication_wal_retention_segments: AtomicUsize::new(0),
3558            backup_pins: Arc::new(Mutex::new(HashMap::new())),
3559            spill_hook: Mutex::new(None),
3560            security_commit_hook: Mutex::new(None),
3561            catalog_commit_hook: Mutex::new(None),
3562            backup_hook: Mutex::new(None),
3563            fk_lock_hook: Mutex::new(None),
3564            replication_hook: Mutex::new(None),
3565            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
3566            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
3567            trigger_max_loop_iterations: AtomicU32::new(
3568                TriggerConfig::default().max_loop_iterations,
3569            ),
3570            notify: {
3571                let (tx, _rx) = tokio::sync::broadcast::channel(256);
3572                tx
3573            },
3574            change_wake,
3575            _lock: Mutex::new(Some(lock)),
3576        };
3577        Ok(Self {
3578            core: Arc::new(core),
3579            principal: RwLock::new(principal),
3580            auth_state,
3581            shared: false,
3582        })
3583    }
3584
3585    /// The current reader-visible epoch.
3586    pub fn visible_epoch(&self) -> Epoch {
3587        self.epoch.visible()
3588    }
3589
3590    /// The catalog's monotonic command version (S1F-001).
3591    pub fn catalog_version(&self) -> u64 {
3592        self.catalog.read().catalog_version
3593    }
3594
3595    /// The durable storage mode recorded for this database (spec section 5.3).
3596    /// `None` only while a legacy pre-marker database is mid-open (the marker
3597    /// is backfilled before open completes).
3598    pub fn storage_mode(&self) -> Result<Option<crate::storage_mode::StorageMode>> {
3599        Ok(crate::storage_mode::read(&self.durable_root)?)
3600    }
3601
3602    /// The core's node-level memory governor (S1E-003). Exactly one per core;
3603    /// the page caches reserve under it and register as reclaimable.
3604    pub fn memory_governor(&self) -> &crate::memory::MemoryGovernor {
3605        &self.memory_governor
3606    }
3607
3608    /// Workload resource-group registry (S1E-002). Seeded with defaults for
3609    /// every [`crate::resource::WorkloadClass`] at open.
3610    pub fn resource_groups(&self) -> &crate::resource::ResourceGroupRegistry {
3611        &self.resource_groups
3612    }
3613
3614    /// Process-local embedding provider registry. Core storage never hard-codes
3615    /// an external vendor; register local/remote providers here when generation
3616    /// is desired. Application-supplied vectors need no registration.
3617    pub fn embedding_providers(&self) -> &crate::embedding::EmbeddingProviderRegistry {
3618        &self.embedding_providers
3619    }
3620
3621    /// Acquire exclusive row locks for `SELECT ... FOR UPDATE` (spec §10.2).
3622    /// Locks are held until the transaction ends (`release_txn_locks` /
3623    /// commit / abort). Requires an open transaction id from the SQL session.
3624    pub fn lock_rows_for_update(
3625        &self,
3626        txn_id: u64,
3627        table_id: u64,
3628        row_ids: &[crate::rowid::RowId],
3629        control: Option<&crate::ExecutionControl>,
3630    ) -> Result<()> {
3631        for &row_id in row_ids {
3632            self.acquire_txn_lock(
3633                txn_id,
3634                crate::locks::LockKey::row(table_id, row_id),
3635                crate::locks::LockMode::Exclusive,
3636                control,
3637            )?;
3638        }
3639        Ok(())
3640    }
3641
3642    /// The core's node-level spill manager (S1E-004), rooted at
3643    /// `<db-root>/temp/spill`. Query engines open per-query spill sessions
3644    /// from it.
3645    pub fn spill_manager(&self) -> &crate::spill::SpillManager {
3646        &self.spill_manager
3647    }
3648
3649    /// The core's durable job registry (S1F-002, sibling `JOBS` file).
3650    pub fn job_registry(&self) -> &Arc<crate::jobs::JobRegistry> {
3651        &self.job_registry
3652    }
3653
3654    /// S1C-004 diagnostics: every mounted table's active version-retention
3655    /// pin sources, aggregated from [`crate::engine::Table::version_pins_report`].
3656    /// A version may be reclaimed only when it is older than the oldest pin of
3657    /// every source; this report exposes each of them per table.
3658    pub fn version_pins_report(&self) -> Vec<TablePinsReport> {
3659        let names: HashMap<u64, String> = self
3660            .catalog
3661            .read()
3662            .tables
3663            .iter()
3664            .map(|entry| (entry.table_id, entry.name.clone()))
3665            .collect();
3666        let handles: Vec<_> = self
3667            .tables
3668            .read()
3669            .iter()
3670            .map(|(table_id, handle)| (*table_id, handle.clone()))
3671            .collect();
3672        handles
3673            .into_iter()
3674            .map(|(table_id, handle)| {
3675                let pins = handle.lock().version_pins_report();
3676                TablePinsReport {
3677                    table_id,
3678                    table: names.get(&table_id).cloned().unwrap_or_default(),
3679                    pins,
3680                }
3681            })
3682            .collect()
3683    }
3684
3685    /// The core's key/predicate lock manager (S1B-003).
3686    pub fn lock_manager(&self) -> &Arc<crate::locks::LockManager> {
3687        &self.lock_manager
3688    }
3689
3690    /// S1B-004 step 12: release every lock `txn_id` holds (commit, abort, and
3691    /// rollback all funnel here). Idempotent — releasing a transaction with
3692    /// no holds is a no-op.
3693    /// Release every lock held by `txn_id` (COMMIT/ROLLBACK/FOR UPDATE end).
3694    pub fn release_txn_locks(&self, txn_id: u64) {
3695        self.lock_manager.release_all(txn_id);
3696    }
3697
3698    /// Build a lock request for `txn_id`, inheriting the commit's
3699    /// cancellation control (or a plain one when the commit is uncontrolled).
3700    fn txn_lock_request(
3701        txn_id: u64,
3702        mode: crate::locks::LockMode,
3703        control: Option<&crate::ExecutionControl>,
3704    ) -> crate::locks::LockRequest {
3705        let control = control
3706            .cloned()
3707            .unwrap_or_else(|| crate::ExecutionControl::new(None));
3708        crate::locks::LockRequest::new(txn_id, mode, control)
3709    }
3710
3711    /// Acquire one lock for `txn_id`, bridging the typed [`crate::locks::LockError`]
3712    /// onto the engine error (deadlock victims surface as
3713    /// [`MongrelError::Deadlock`] with [`mongreldb_types::error::ErrorCategory::Deadlock`]).
3714    pub(crate) fn acquire_txn_lock(
3715        &self,
3716        txn_id: u64,
3717        key: crate::locks::LockKey,
3718        mode: crate::locks::LockMode,
3719        control: Option<&crate::ExecutionControl>,
3720    ) -> Result<()> {
3721        self.lock_manager
3722            .acquire(key, Self::txn_lock_request(txn_id, mode, control))
3723            .map_err(MongrelError::from)
3724    }
3725
3726    /// S1B-003: acquire the schema barrier Exclusive for one DDL operation.
3727    /// The hold releases when the returned guard drops (end of the DDL entry
3728    /// point). DML transactions hold the barrier Shared for their whole
3729    /// commit, so schema changes exclude concurrent DML and one another.
3730    fn acquire_schema_barrier_exclusive(&self) -> Result<TxnLockGuard<'_>> {
3731        let txn_id = self.alloc_txn_id()?;
3732        self.acquire_txn_lock(
3733            txn_id,
3734            crate::locks::LockKey::schema_barrier(),
3735            crate::locks::LockMode::Exclusive,
3736            None,
3737        )?;
3738        Ok(TxnLockGuard {
3739            locks: &self.lock_manager,
3740            txn_id,
3741        })
3742    }
3743
3744    /// The commit-log authority for this database (spec §9.4, FND-004). Every
3745    /// commit path proposes through it and visibility publication is gated on
3746    /// its receipts; Stage 2 swaps this standalone adapter for the replicated
3747    /// implementation behind the same `CommitLog` trait.
3748    pub fn commit_log(&self) -> Arc<crate::commit_log::StandaloneCommitLog> {
3749        Arc::clone(&self.commit_log)
3750    }
3751
3752    /// The node's HLC timestamp authority (spec §8.2, ADR-0003). Transaction
3753    /// `begin` captures read timestamps here; the commit sequencer allocates
3754    /// commit timestamps from the same clock so commit ts > every participant
3755    /// read/write timestamp of the transaction.
3756    pub(crate) fn hlc_clock(&self) -> &mongreldb_types::hlc::HlcClock {
3757        &self.hlc
3758    }
3759
3760    /// Clone the in-memory catalog (for diagnostics / tests).
3761    pub fn catalog_snapshot(&self) -> Catalog {
3762        self.catalog.read().clone()
3763    }
3764
3765    /// Read SQLite-compatible application metadata persisted in the catalog.
3766    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
3767        let catalog = self.catalog.read();
3768        match key {
3769            "user_version" => Ok(catalog.user_version),
3770            "application_id" => Ok(catalog.application_id),
3771            _ => Err(MongrelError::InvalidArgument(format!(
3772                "unsupported persistent SQL pragma {key:?}"
3773            ))),
3774        }
3775    }
3776
3777    /// Persist SQLite-compatible application metadata and return its exact
3778    /// publication epoch. An unchanged value performs no durable write.
3779    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
3780        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
3781    }
3782
3783    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
3784        &self,
3785        key: &str,
3786        value: i64,
3787        mut before_commit: F,
3788    ) -> Result<Option<Epoch>>
3789    where
3790        F: FnMut() -> Result<()>,
3791    {
3792        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
3793    }
3794
3795    fn set_sql_pragma_i64_with_epoch_inner(
3796        &self,
3797        key: &str,
3798        value: i64,
3799        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
3800    ) -> Result<Option<Epoch>> {
3801        use crate::wal::DdlOp;
3802
3803        self.require(&crate::auth::Permission::Ddl)?;
3804        if self.read_only {
3805            return Err(MongrelError::ReadOnlyReplica);
3806        }
3807        if self.poisoned.load(Ordering::Relaxed) {
3808            return Err(MongrelError::Other(
3809                "database poisoned by fsync error".into(),
3810            ));
3811        }
3812        let _ddl = self.ddl_lock.lock();
3813        let _security_write = self.security_write()?;
3814        self.require(&crate::auth::Permission::Ddl)?;
3815        let mut next_catalog = self.catalog.read().clone();
3816        let target = match key {
3817            "user_version" => &mut next_catalog.user_version,
3818            "application_id" => &mut next_catalog.application_id,
3819            _ => {
3820                return Err(MongrelError::InvalidArgument(format!(
3821                    "unsupported persistent SQL pragma {key:?}"
3822                )))
3823            }
3824        };
3825        if *target == Some(value) {
3826            return Ok(None);
3827        }
3828        *target = Some(value);
3829
3830        let _commit = self.commit_lock.lock();
3831        let epoch = self.epoch.bump_assigned();
3832        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3833        let txn_id = self.alloc_txn_id()?;
3834        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
3835        let commit_seq = {
3836            let mut wal = self.shared_wal.lock();
3837            if let Some(before_commit) = before_commit {
3838                before_commit()?;
3839            }
3840            let append: Result<u64> = (|| {
3841                wal.append(
3842                    txn_id,
3843                    WAL_TABLE_ID,
3844                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
3845                        key: key.to_string(),
3846                        value,
3847                    }),
3848                )?;
3849                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
3850                wal.append_commit(txn_id, epoch, &[])
3851            })();
3852            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
3853        };
3854        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
3855        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
3856        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
3857        Ok(Some(epoch))
3858    }
3859
3860    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
3861        self.catalog
3862            .read()
3863            .materialized_views
3864            .iter()
3865            .find(|definition| definition.name == name)
3866            .cloned()
3867    }
3868
3869    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
3870        self.catalog.read().materialized_views.clone()
3871    }
3872
3873    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
3874        self.catalog.read().security.clone()
3875    }
3876
3877    pub fn security_active_for(&self, table: &str) -> bool {
3878        self.catalog.read().security.table_has_security(table)
3879    }
3880
3881    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
3882        if self.catalog.read().security_version == expected_version {
3883            return Ok(());
3884        }
3885        self.security_catalog_disk_reads
3886            .fetch_add(1, Ordering::Relaxed);
3887        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
3888            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
3889        let principal = self.principal.read().clone();
3890        let principal = if fresh.require_auth {
3891            principal
3892                .as_ref()
3893                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
3894        } else {
3895            principal
3896        };
3897        self.auth_state.set_require_auth(fresh.require_auth);
3898        *self.catalog.write() = fresh;
3899        *self.principal.write() = principal.clone();
3900        self.auth_state.set_principal(principal);
3901        Ok(())
3902    }
3903
3904    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
3905        let guard = self.security_coordinator.gate.write();
3906        let version = self.security_coordinator.version.load(Ordering::Acquire);
3907        self.refresh_security_catalog_if_stale(version)?;
3908        Ok(guard)
3909    }
3910
3911    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
3912    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
3913    /// only its restart checkpoint.
3914    /// S1A-004: admit one operation against the core (rejects once the core
3915    /// leaves [`crate::core::LifecycleState::Open`] — draining, closing,
3916    /// closed, or poisoned). The returned guard holds the operation slot
3917    /// until dropped, so `shutdown()` drains exactly the covered operations.
3918    ///
3919    /// Covered set (the mutating/durable paths; the legacy fsync-poison error
3920    /// still wins where a body already checks `self.poisoned`, because the
3921    /// guard is taken after that check):
3922    ///
3923    /// - the cross-table commit funnel
3924    ///   (`commit_transaction_with_external_states_inner`), covering every
3925    ///   user and internal transaction commit;
3926    /// - the catalog publish funnel (`publish_catalog_candidate_with_prelude`),
3927    ///   covering the user/role/grant, trigger, and procedure families;
3928    /// - the inline-WAL DDL bodies: table create/drop/rename/alter, CTAS
3929    ///   rebuilding publish, security-catalog and materialized-view
3930    ///   replacement, external-table create/drop/reset;
3931    /// - storage maintenance: `gc`, `checkpoint`, `compact`, `hot_backup`;
3932    /// - replication bootstrap, batch extraction, and follow-apply.
3933    ///
3934    /// Read-only entry points (snapshots, queries, stats) and the infallible
3935    /// `begin*` constructors are intentionally not covered: reads never block
3936    /// shutdown, and a transaction's durable act is its commit.
3937    pub(crate) fn admit_operation(&self) -> Result<crate::core::OperationGuard> {
3938        self.core.operation_guard()
3939    }
3940
3941    /// S1F-001: wrap `command` in its versioned record and apply it to the
3942    /// catalog candidate (validate → mutate → bump `catalog_version` → append
3943    /// the bounded command history). Security-catalog changes advance the
3944    /// security version inside `CatalogDelta::apply_to`, exactly where the
3945    /// legacy mutation bodies called `advance_security_version` themselves.
3946    fn apply_catalog_command_to(
3947        &self,
3948        next_catalog: &mut Catalog,
3949        command: crate::catalog_cmds::CatalogCommand,
3950    ) -> Result<crate::catalog_cmds::CatalogDelta> {
3951        let record = crate::catalog_cmds::CatalogCommandRecord::next(next_catalog, command);
3952        next_catalog.apply_command(&record)
3953    }
3954
3955    /// Stage 2E (spec section 11.5): apply one committed replicated
3956    /// transaction's staged records through the **same** logic the WAL
3957    /// recovery path uses ([`recover_shared_wal`]).
3958    ///
3959    /// The payload is a complete record sequence of one committed transaction
3960    /// (data ops, `Op::CommitTimestamp`, and exactly one trailing
3961    /// `Op::TxnCommit`), carrying the leader-assigned commit epoch so every
3962    /// replica applies byte-identical records deterministically.
3963    ///
3964    /// Application is durable before it returns: the records are appended
3965    /// verbatim to the core's shared WAL and group-synced, so the raft state
3966    /// machine's post-apply checkpoint never acknowledges rows a crash would
3967    /// lose. Application is idempotent: a payload whose commit epoch is at or
3968    /// below the core's visible watermark is a replay (the state machine
3969    /// dispatches sink-first, checkpoints second — a crash in that window
3970    /// redelivers) and is skipped without side effects. Returns `Ok(true)`
3971    /// when the payload was applied, `Ok(false)` for a recognized replay.
3972    pub fn apply_replicated_records(&self, records: &[crate::wal::Record]) -> Result<bool> {
3973        use crate::wal::Op;
3974        use std::sync::atomic::Ordering;
3975
3976        let _operation = self.admit_operation()?;
3977        if self.poisoned.load(Ordering::Relaxed) {
3978            return Err(MongrelError::Other(
3979                "database poisoned by fsync error".into(),
3980            ));
3981        }
3982        // Structural validation (fail closed): one transaction, exactly one
3983        // commit marker, at the tail.
3984        let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
3985            MongrelError::InvalidArgument("replicated transaction payload is empty".into())
3986        })?;
3987        if records.iter().any(|record| record.txn_id != txn_id) {
3988            return Err(MongrelError::InvalidArgument(
3989                "replicated transaction payload mixes transaction ids".into(),
3990            ));
3991        }
3992        let commits = records
3993            .iter()
3994            .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
3995            .count();
3996        if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
3997            return Err(MongrelError::InvalidArgument(
3998                "replicated transaction payload must end in exactly one commit marker".into(),
3999            ));
4000        }
4001        let commit_epoch = match records.last().map(|r| &r.op) {
4002            Some(Op::TxnCommit { epoch, .. }) => *epoch,
4003            _ => unreachable!("validated above"),
4004        };
4005        // Fail closed on spilled-run linking: an `added_runs` commit links
4006        // run files that exist only on the leader. Leaders never emit such a
4007        // payload — the Stage 2C envelope builder passes every commit through
4008        // [`translate_records_for_replication`], which stages the spilled rows
4009        // as logical `Op::Put` records and strips the run links (spec section
4010        // 11.3 step 3). This check is the defense-in-depth gate behind that
4011        // contract: a payload carrying run references is un-appliable here
4012        // and is rejected deterministically rather than diverging.
4013        if let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) {
4014            if !added_runs.is_empty() {
4015                return Err(MongrelError::InvalidArgument(
4016                    "replicated spilled-run commits are not appliable: the leader must translate \
4017                     the commit through translate_records_for_replication before proposal"
4018                        .into(),
4019                ));
4020            }
4021        }
4022        // Replay guard: the leader assigns one monotonically increasing epoch
4023        // per committed transaction in log order, so anything at or below the
4024        // core's watermark is already applied.
4025        if commit_epoch <= self.epoch.visible().0 {
4026            return Ok(false);
4027        }
4028        // Stage durable first: verbatim WAL append + fsync. The raft log is
4029        // the commit authority; the local WAL is this replica's durable
4030        // staging of already-committed commands (restart recovery replays it
4031        // through the identical path, gated by flushed epochs).
4032        {
4033            let mut wal = self.shared_wal.lock();
4034            for record in records {
4035                wal.append(record.txn_id, 0, record.op.clone())?;
4036            }
4037            if let Err(error) = wal.group_sync() {
4038                self.poisoned.store(true, Ordering::Relaxed);
4039                return Err(error);
4040            }
4041        }
4042        // S1C-004: pin the pre-apply visible epoch for the duration of apply so
4043        // concurrent GC cannot reclaim versions a catch-up reader still needs.
4044        let replication_pins: Vec<crate::retention::PinGuard> = {
4045            let tables = self.tables.read();
4046            let floor = Epoch(self.epoch.visible().0);
4047            tables
4048                .values()
4049                .map(|handle| {
4050                    let t = handle.lock();
4051                    Arc::clone(t.pin_registry())
4052                        .pin(crate::retention::PinSource::Replication, floor)
4053                })
4054                .collect()
4055        };
4056        let tables = self.tables.read().clone();
4057        let catalog = self.catalog.read().clone();
4058        recover_shared_wal(&self.durable_root, &tables, &catalog, &self.epoch, records)?;
4059        // Replica-side commit-ts ledger: record the leader-assigned commit
4060        // epoch's physical time from any CommitTimestamp op, else stamp from
4061        // the local HLC so `commit_ts_for_epoch` serves PITR/read-your-writes
4062        // on replicas (Stage 2 residual).
4063        if let Some(Op::TxnCommit { epoch, .. }) = records.last().map(|r| &r.op) {
4064            let commit_ts = records
4065                .iter()
4066                .rev()
4067                .find_map(|record| match &record.op {
4068                    Op::CommitTimestamp { unix_nanos } => {
4069                        Some(mongreldb_types::hlc::HlcTimestamp {
4070                            physical_micros: unix_nanos / 1_000,
4071                            logical: 0,
4072                            node_tiebreaker: 0,
4073                        })
4074                    }
4075                    _ => None,
4076                })
4077                .unwrap_or_else(|| {
4078                    self.hlc
4079                        .now()
4080                        .unwrap_or(mongreldb_types::hlc::HlcTimestamp {
4081                            physical_micros: 0,
4082                            logical: 0,
4083                            node_tiebreaker: 0,
4084                        })
4085                });
4086            self.record_commit_ts(Epoch(*epoch), commit_ts);
4087        }
4088        drop(replication_pins);
4089        Ok(true)
4090    }
4091
4092    /// Stage 3H engine binding (spec section 12.8): validate staged
4093    /// write-intent payloads at prepare time. A participant that durably
4094    /// prepares these payloads must be able to apply them at resolution, so
4095    /// every check the resolution apply performs runs here first: each
4096    /// payload decodes as a [`StagedTxnWrite`], its table is mounted, and
4097    /// every staged row satisfies the same persisted-row validation the WAL
4098    /// recovery path applies. A malformed payload is rejected deterministically
4099    /// at prepare — never after a decision commits, where a rejection would
4100    /// wedge the replicated apply stream.
4101    pub fn validate_staged_txn_writes(&self, staged: &[Vec<u8>]) -> Result<()> {
4102        let tables = self.tables.read();
4103        for payload in staged {
4104            match StagedTxnWrite::decode(payload)? {
4105                StagedTxnWrite::Put { table_id, rows } => {
4106                    let handle = tables.get(&table_id).ok_or_else(|| {
4107                        MongrelError::InvalidArgument(format!(
4108                            "staged write targets unmounted table {table_id}"
4109                        ))
4110                    })?;
4111                    let rows: Vec<crate::memtable::Row> =
4112                        bincode::deserialize(&rows).map_err(|error| {
4113                            MongrelError::InvalidArgument(format!(
4114                                "staged put payload for table {table_id} cannot decode: {error}"
4115                            ))
4116                        })?;
4117                    let schema = handle.lock().schema().clone();
4118                    for row in &rows {
4119                        validate_recovered_row(&schema, row).map_err(|error| {
4120                            MongrelError::InvalidArgument(format!(
4121                                "staged row for table {table_id} is not appliable: {error}"
4122                            ))
4123                        })?;
4124                    }
4125                }
4126                StagedTxnWrite::Delete { table_id, row_ids } => {
4127                    if !tables.contains_key(&table_id) {
4128                        return Err(MongrelError::InvalidArgument(format!(
4129                            "staged delete targets unmounted table {table_id}"
4130                        )));
4131                    }
4132                    if row_ids.contains(&u64::MAX) {
4133                        return Err(MongrelError::InvalidArgument(format!(
4134                            "staged delete for table {table_id} names an exhausted row id"
4135                        )));
4136                    }
4137                }
4138            }
4139        }
4140        Ok(())
4141    }
4142
4143    /// Stage 3H engine binding (spec section 12.8): apply one committed
4144    /// resolution's staged writes through the replicated apply path
4145    /// ([`Database::apply_replicated_records`]) at the decision's commit
4146    /// timestamp. `txn_tag` is the caller's deterministic per-transaction
4147    /// tag (e.g. a hash of the distributed transaction id); it must be
4148    /// identical on every replica. The synthetic WAL transaction id is
4149    /// derived from it inside the current open-generation namespace with the
4150    /// allocator-avoidance bit set, so resolution records pass the
4151    /// open-generation durability check on reopen and never alias
4152    /// leader-allocated transaction ids.
4153    ///
4154    /// The commit epoch stamped into the synthetic commit marker is one past
4155    /// the core's visible watermark: the replicated apply stream is
4156    /// deterministic, so every replica computes the identical epoch at the
4157    /// identical apply point, and the core's own restart recovery replays the
4158    /// stamped sequence verbatim. Rows are restamped at that epoch by the
4159    /// recovery logic, becoming visible exactly as an ordinary committed
4160    /// transaction's rows.
4161    pub fn apply_staged_txn_writes(
4162        &self,
4163        txn_tag: u64,
4164        staged: &[Vec<u8>],
4165        commit_ts: mongreldb_types::hlc::HlcTimestamp,
4166    ) -> Result<bool> {
4167        use crate::wal::Op;
4168
4169        // Decode every payload before any mutation (fail closed).
4170        let mut writes = Vec::with_capacity(staged.len());
4171        for payload in staged {
4172            writes.push(StagedTxnWrite::decode(payload)?);
4173        }
4174        // Generation-namespace the synthetic transaction id: the high 32 bits
4175        // are the current open generation (the reopen path rejects retained
4176        // records from a generation beyond the durable WAL head); the low 32
4177        // bits carry the caller's tag with the top bit set, outside the
4178        // ascending leader-allocated counter space.
4179        let generation = *self.next_txn_id.lock() >> 32;
4180        let txn_id = (generation << 32) | (txn_tag & 0x7FFF_FFFF) | 0x8000_0000;
4181        let mut records = Vec::with_capacity(writes.len() + 2);
4182        for write in writes {
4183            let op = match write {
4184                StagedTxnWrite::Put { table_id, rows } => Op::Put { table_id, rows },
4185                StagedTxnWrite::Delete { table_id, row_ids } => Op::Delete {
4186                    table_id,
4187                    row_ids: row_ids.into_iter().map(crate::RowId).collect(),
4188                },
4189            };
4190            records.push(crate::wal::Record::new(Epoch(0), txn_id, op));
4191        }
4192        let epoch = self.epoch.visible().0 + 1;
4193        // The physical component of the decision's commit timestamp goes into
4194        // the durable timestamp ledger, mirroring the ordinary commit path
4195        // (`commit_log::commit_nanos`).
4196        let unix_nanos = commit_ts.physical_micros.saturating_mul(1_000);
4197        records.push(crate::wal::Record::new(
4198            Epoch(0),
4199            txn_id,
4200            Op::CommitTimestamp { unix_nanos },
4201        ));
4202        records.push(crate::wal::Record::new(
4203            Epoch(0),
4204            txn_id,
4205            Op::TxnCommit {
4206                epoch,
4207                added_runs: Vec::new(),
4208            },
4209        ));
4210        self.apply_replicated_records(&records)
4211    }
4212
4213    /// Stage 2E (spec sections 10.6, 11.5): apply one committed replicated
4214    /// catalog command and checkpoint the catalog. The record travels as the
4215    /// payload of a replicated `Catalog` command envelope and routes through
4216    /// [`Catalog::apply_command`] — the S1F-001 versioned, idempotent command
4217    /// path (replaying an already-applied `catalog_version` is a no-op).
4218    /// Structural deltas are mirrored into the mounted table set: a created
4219    /// table is mounted, a dropped table unmounted. Deterministic: the record
4220    /// carries every resolved value (ids, epochs, complete images).
4221    pub fn apply_replicated_catalog_command(
4222        &self,
4223        record: &crate::catalog_cmds::CatalogCommandRecord,
4224    ) -> Result<crate::catalog_cmds::CatalogDelta> {
4225        let _operation = self.admit_operation()?;
4226        let _g = self.ddl_lock.lock();
4227        let mut next_catalog = self.catalog.read().clone();
4228        let delta = next_catalog.apply_command(record)?;
4229        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
4230            return Ok(delta);
4231        }
4232        // The leader references epochs in structural commands (a table's
4233        // creation/drop epoch). The replica's epoch stream must cover them:
4234        // epochs stay the commit sequencer's authority, so commands never
4235        // allocate one, but the watermark advances to every referenced epoch
4236        // (mirroring how `create_table_with_state` bumps `db_epoch`).
4237        let referenced_epoch = match &delta {
4238            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => Some(entry.created_epoch),
4239            crate::catalog_cmds::CatalogDelta::TableDropped { at_epoch, .. }
4240            | crate::catalog_cmds::CatalogDelta::TableRenamed { at_epoch, .. } => Some(*at_epoch),
4241            _ => None,
4242        };
4243        if let Some(referenced) = referenced_epoch {
4244            self.epoch.advance_recovered(Epoch(referenced));
4245            next_catalog.db_epoch = next_catalog.db_epoch.max(referenced);
4246        }
4247        match &delta {
4248            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => {
4249                // Guard against a repeated mount within one open (the state
4250                // machine's crash-window redispatch is filtered by the NoOp
4251                // arm above; this covers a catalog checkpoint that never
4252                // became durable before a crash).
4253                if !self.tables.read().contains_key(&entry.table_id) {
4254                    self.mount_catalog_entry(entry)?;
4255                }
4256            }
4257            crate::catalog_cmds::CatalogDelta::TableDropped { table_id, .. } => {
4258                self.tables.write().remove(table_id);
4259            }
4260            _ => {}
4261        }
4262        // Durable BEFORE return: the catalog checkpoint is the only local
4263        // durable record of the command (replicated catalog commands do not
4264        // ride the local WAL), and the state machine checkpoints right after.
4265        catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref())?;
4266        *self.catalog.write() = next_catalog;
4267        Ok(delta)
4268    }
4269
4270    /// Mount a table that a replicated catalog command created (Stage 2E):
4271    /// create the table directory and build the mounted table exactly like
4272    /// the create-table path does, minus the standalone WAL/commit side
4273    /// effects (the command itself is already durable in the raft log).
4274    fn mount_catalog_entry(&self, entry: &crate::catalog::CatalogEntry) -> Result<()> {
4275        let table_relative = Path::new(TABLES_DIR).join(entry.table_id.to_string());
4276        let table_root = Arc::new(
4277            self.durable_root
4278                .create_directory_all_pinned(&table_relative)?,
4279        );
4280        let tdir = table_root.io_path()?;
4281        let ctx = SharedCtx {
4282            root_guard: Some(table_root),
4283            epoch: Arc::clone(&self.epoch),
4284            page_cache: Arc::clone(&self.page_cache),
4285            decoded_cache: Arc::clone(&self.decoded_cache),
4286            snapshots: Arc::clone(&self.snapshots),
4287            kek: self.kek.clone(),
4288            commit_lock: Arc::clone(&self.commit_lock),
4289            shared: Some(crate::engine::SharedWalCtx {
4290                wal: Arc::clone(&self.shared_wal),
4291                group: Arc::clone(&self.group),
4292                poisoned: Arc::clone(&self.poisoned),
4293                txn_ids: Arc::clone(&self.next_txn_id),
4294                change_wake: self.change_wake.clone(),
4295                lifecycle: Arc::clone(&self.lifecycle),
4296            }),
4297            table_name: Some(entry.name.clone()),
4298            auth: self.table_auth_checker(),
4299            read_only: self.read_only,
4300        };
4301        let table = Table::create_in(&tdir, entry.schema.clone(), entry.table_id, ctx)?;
4302        self.tables
4303            .write()
4304            .insert(entry.table_id, TableHandle::new(table));
4305        Ok(())
4306    }
4307
4308    fn publish_catalog_candidate(
4309        &self,
4310        catalog: Catalog,
4311        epoch: Epoch,
4312        epoch_guard: &mut EpochGuard<'_>,
4313        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
4314    ) -> Result<()> {
4315        self.publish_catalog_candidate_with_prelude(
4316            catalog,
4317            epoch,
4318            epoch_guard,
4319            before_publish,
4320            Vec::new(),
4321        )
4322    }
4323
4324    fn publish_catalog_candidate_with_prelude(
4325        &self,
4326        catalog: Catalog,
4327        epoch: Epoch,
4328        epoch_guard: &mut EpochGuard<'_>,
4329        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
4330        prelude: Vec<(u64, crate::wal::Op)>,
4331    ) -> Result<()> {
4332        use crate::wal::DdlOp;
4333
4334        if self.read_only {
4335            return Err(MongrelError::ReadOnlyReplica);
4336        }
4337        if self.poisoned.load(Ordering::Relaxed) {
4338            return Err(MongrelError::Other(
4339                "database poisoned by fsync error".into(),
4340            ));
4341        }
4342        // S1A-004: admit the catalog publish as one core operation.
4343        let _operation = self.admit_operation()?;
4344        if let Some(before_publish) = before_publish.as_mut() {
4345            (**before_publish)()?;
4346        }
4347        if catalog.db_epoch != epoch.0 {
4348            return Err(MongrelError::InvalidArgument(format!(
4349                "catalog epoch {} does not match commit epoch {}",
4350                catalog.db_epoch, epoch.0
4351            )));
4352        }
4353        {
4354            let current = self.catalog.read();
4355            validate_catalog_transition(&current, &catalog)?;
4356        }
4357        validate_recovered_catalog(&catalog)?;
4358        let catalog_json = DdlOp::encode_catalog(&catalog)?;
4359        let txn_id = self.alloc_txn_id()?;
4360        let commit_seq = {
4361            let mut wal = self.shared_wal.lock();
4362            let append: Result<u64> = (|| {
4363                for (table_id, op) in prelude {
4364                    wal.append(txn_id, table_id, op)?;
4365                }
4366                wal.append(
4367                    txn_id,
4368                    WAL_TABLE_ID,
4369                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
4370                )?;
4371                wal.append_commit(txn_id, epoch, &[])
4372            })();
4373            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4374        };
4375        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4376        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
4377        self.finish_durable_publish(epoch, epoch_guard, &receipt, checkpoint)
4378    }
4379
4380    /// A WAL commit is already durable. Publish the matching catalog in memory
4381    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
4382    /// while the live handle must never continue with pre-commit metadata.
4383    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
4384        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
4385        let version = catalog.security_version;
4386        let principal = self.principal.read().clone();
4387        let principal = if catalog.require_auth {
4388            principal.as_ref().and_then(|principal| {
4389                Self::resolve_bound_principal_from_catalog(&catalog, principal)
4390            })
4391        } else {
4392            principal
4393        };
4394        *self.catalog.write() = catalog;
4395        self.security_coordinator
4396            .version
4397            .store(version, Ordering::Release);
4398        self.auth_state
4399            .set_require_auth(self.catalog.read().require_auth);
4400        *self.principal.write() = principal.clone();
4401        self.auth_state.set_principal(principal);
4402        checkpoint
4403    }
4404
4405    fn finish_durable_publish(
4406        &self,
4407        epoch: Epoch,
4408        epoch_guard: &mut EpochGuard<'_>,
4409        receipt: &mongreldb_log::CommitReceipt,
4410        post_step: Result<()>,
4411    ) -> Result<()> {
4412        if let Err(error) = self.publish_committed(receipt, epoch) {
4413            // The commit marker is durable but runtime publication failed. The
4414            // epoch guard stays armed so the assigned ticket is abandoned (the
4415            // watermark skips it), and the live handle poisons exactly like any
4416            // other post-durable failure.
4417            self.poisoned.store(true, Ordering::Relaxed);
4418            self.lifecycle.poison();
4419            return Err(MongrelError::DurableCommit {
4420                epoch: epoch.0,
4421                message: error.to_string(),
4422            });
4423        }
4424        epoch_guard.disarm();
4425        match post_step {
4426            Ok(()) => Ok(()),
4427            Err(error) => {
4428                self.poisoned.store(true, Ordering::Relaxed);
4429                self.lifecycle.poison();
4430                Err(MongrelError::DurableCommit {
4431                    epoch: epoch.0,
4432                    message: error.to_string(),
4433                })
4434            }
4435        }
4436    }
4437
4438    /// Advance reader visibility for a committed epoch. Publication is gated on
4439    /// the commit log's receipt (spec §9.4, FND-004): visibility only ever
4440    /// covers commands the commit log acknowledged durable. The
4441    /// `commit.publish.before`/`commit.publish.after` fault hooks bracket the
4442    /// watermark advance (spec §9.6, FND-006).
4443    fn publish_committed(
4444        &self,
4445        receipt: &mongreldb_log::CommitReceipt,
4446        epoch: Epoch,
4447    ) -> Result<()> {
4448        debug_assert_eq!(
4449            receipt.log_position.index, epoch.0,
4450            "commit receipt position must match the published epoch"
4451        );
4452        mongreldb_fault::inject("commit.publish.before").map_err(crate::commit_log::fault_as_io)?;
4453        self.epoch.publish_in_order(epoch);
4454        mongreldb_fault::inject("commit.publish.after").map_err(crate::commit_log::fault_as_io)?;
4455        Ok(())
4456    }
4457
4458    /// Wait for a commit marker to reach stable storage and return the commit
4459    /// log's irrevocable receipt (spec §9.4, FND-004). A failed append/fsync
4460    /// acknowledgement is ambiguous, so poison the live handle and preserve
4461    /// the assigned epoch in a structured unknown-outcome error.
4462    ///
4463    /// Used by the DDL/maintenance commit paths, which do not pre-assign a
4464    /// commit timestamp: the receipt's `commit_ts` is allocated from the
4465    /// node's HLC clock at seal time.
4466    fn await_durable_commit(
4467        &self,
4468        txn_id: u64,
4469        commit_seq: u64,
4470        epoch: Epoch,
4471    ) -> Result<mongreldb_log::CommitReceipt> {
4472        match self
4473            .commit_log
4474            .seal_transaction(txn_id, epoch, commit_seq, None)
4475        {
4476            Ok(receipt) => {
4477                self.record_commit_ts(epoch, receipt.commit_ts);
4478                Ok(receipt)
4479            }
4480            Err(error) => {
4481                self.poisoned.store(true, Ordering::Relaxed);
4482                self.lifecycle.poison();
4483                Err(MongrelError::CommitOutcomeUnknown {
4484                    epoch: epoch.0,
4485                    message: error.to_string(),
4486                })
4487            }
4488        }
4489    }
4490
4491    /// [`Self::await_durable_commit`] for the transaction commit sequencer,
4492    /// which assigned `commit_ts` under the sequencer lock (S1B-004 step 5,
4493    /// spec §8.2). The receipt carries that exact timestamp, matching the
4494    /// durable `Op::CommitTimestamp` ledger record written at append.
4495    fn await_durable_commit_with_ts(
4496        &self,
4497        txn_id: u64,
4498        commit_seq: u64,
4499        epoch: Epoch,
4500        commit_ts: mongreldb_types::hlc::HlcTimestamp,
4501    ) -> Result<mongreldb_log::CommitReceipt> {
4502        match self
4503            .commit_log
4504            .seal_transaction(txn_id, epoch, commit_seq, Some(commit_ts))
4505        {
4506            Ok(receipt) => {
4507                self.record_commit_ts(epoch, receipt.commit_ts);
4508                Ok(receipt)
4509            }
4510            Err(error) => {
4511                self.poisoned.store(true, Ordering::Relaxed);
4512                self.lifecycle.poison();
4513                Err(MongrelError::CommitOutcomeUnknown {
4514                    epoch: epoch.0,
4515                    message: error.to_string(),
4516                })
4517            }
4518        }
4519    }
4520
4521    /// Record one durable commit's receipt timestamp in the per-open ledger
4522    /// (bounded to the newest [`COMMIT_TS_LEDGER_CAP`] epochs). Called by the
4523    /// durability funnels once the commit log has issued the irrevocable
4524    /// receipt.
4525    fn record_commit_ts(&self, epoch: Epoch, commit_ts: mongreldb_types::hlc::HlcTimestamp) {
4526        let mut ledger = self.commit_ts_ledger.lock();
4527        ledger.insert(epoch.0, commit_ts);
4528        while ledger.len() > COMMIT_TS_LEDGER_CAP {
4529            ledger.pop_first();
4530        }
4531    }
4532
4533    /// The commit timestamp of a durable commit, by epoch — the literal
4534    /// write receipt behind the server's read-your-writes token (spec §8.2).
4535    ///
4536    /// Returns `Some` for commits sealed within this open (the exact receipt
4537    /// `HlcTimestamp`) and for commits recovered from the durable
4538    /// `Op::CommitTimestamp` WAL ledger at open; the latter reconstruct the
4539    /// physical component only, with `logical` and `node_tiebreaker` as 0 per
4540    /// the ledger byte format. Only the newest [`COMMIT_TS_LEDGER_CAP`]
4541    /// epochs are retained, and epochs sealed through `CommitLog::propose`
4542    /// (catalog-command proposals) are not recorded here within a live open;
4543    /// both miss shapes return `None`, and callers fall back to a fresh-begin
4544    /// HLC, which the single clock authority orders after every commit it has
4545    /// already issued.
4546    pub fn commit_ts_for_epoch(&self, epoch: Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp> {
4547        self.commit_ts_ledger.lock().get(&epoch.0).copied()
4548    }
4549
4550    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
4551        self.poisoned.store(true, Ordering::Relaxed);
4552        self.lifecycle.poison();
4553        MongrelError::CommitOutcomeUnknown {
4554            epoch: epoch.0,
4555            message: error.to_string(),
4556        }
4557    }
4558
4559    /// Persist a complete validated RLS/masking catalog through the WAL.
4560    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
4561        self.set_security_catalog_as_with_epoch(security, None)
4562            .map(|_| ())
4563    }
4564
4565    /// Persist security policy changes on behalf of an explicit request principal.
4566    pub fn set_security_catalog_as(
4567        &self,
4568        security: crate::security::SecurityCatalog,
4569        principal: Option<&crate::auth::Principal>,
4570    ) -> Result<()> {
4571        self.set_security_catalog_as_with_epoch(security, principal)
4572            .map(|_| ())
4573    }
4574
4575    /// Persist security policy changes and return the exact publication epoch.
4576    pub fn set_security_catalog_as_with_epoch(
4577        &self,
4578        security: crate::security::SecurityCatalog,
4579        principal: Option<&crate::auth::Principal>,
4580    ) -> Result<Epoch> {
4581        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
4582    }
4583
4584    /// Persist security policy changes, entering the commit fence immediately
4585    /// before the first WAL record can become visible to recovery.
4586    pub fn set_security_catalog_as_with_epoch_controlled<F>(
4587        &self,
4588        security: crate::security::SecurityCatalog,
4589        principal: Option<&crate::auth::Principal>,
4590        mut before_commit: F,
4591    ) -> Result<Epoch>
4592    where
4593        F: FnMut() -> Result<()>,
4594    {
4595        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
4596    }
4597
4598    fn set_security_catalog_as_with_epoch_inner(
4599        &self,
4600        security: crate::security::SecurityCatalog,
4601        principal: Option<&crate::auth::Principal>,
4602        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
4603    ) -> Result<Epoch> {
4604        use crate::wal::DdlOp;
4605        use std::sync::atomic::Ordering;
4606
4607        // S1F-001: the mutation is a versioned catalog command; its required
4608        // permission is checked against the caller principal first (identical
4609        // to the legacy hardcoded Admin gate).
4610        let command = crate::catalog_cmds::CatalogCommand::SetSecurityCatalog {
4611            security: security.clone(),
4612        };
4613        self.require_for(
4614            principal,
4615            &crate::catalog_cmds::required_permission(&command),
4616        )?;
4617        if self.poisoned.load(Ordering::Relaxed) {
4618            return Err(MongrelError::Other(
4619                "database poisoned by fsync error".into(),
4620            ));
4621        }
4622        // S1A-004: admit the security-catalog replacement as one core
4623        // operation.
4624        let _operation = self.admit_operation()?;
4625        let _ddl = self.ddl_lock.lock();
4626        // DDL serializes first; write-path order after that is security gate ->
4627        // commit lock -> shared WAL.
4628        let _security_write = self.security_write()?;
4629        self.require_for(
4630            principal,
4631            &crate::catalog_cmds::required_permission(&command),
4632        )?;
4633        let mut next_catalog = self.catalog.read().clone();
4634        validate_security_catalog(&next_catalog, &security)?;
4635        let payload = DdlOp::encode_security(&security)?;
4636        let _commit = self.commit_lock.lock();
4637        let epoch = self.epoch.bump_assigned();
4638        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4639        let txn_id = self.alloc_txn_id()?;
4640        self.apply_catalog_command_to(&mut next_catalog, command)?;
4641        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4642        let commit_seq = {
4643            let mut wal = self.shared_wal.lock();
4644            if let Some(before_commit) = before_commit {
4645                before_commit()?;
4646            }
4647            let append: Result<u64> = (|| {
4648                wal.append(
4649                    txn_id,
4650                    WAL_TABLE_ID,
4651                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
4652                        security_json: payload,
4653                    }),
4654                )?;
4655                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4656                wal.append_commit(txn_id, epoch, &[])
4657            })();
4658            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4659        };
4660        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4661        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4662        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
4663        Ok(epoch)
4664    }
4665
4666    pub fn require_for(
4667        &self,
4668        principal: Option<&crate::auth::Principal>,
4669        permission: &crate::auth::Permission,
4670    ) -> Result<()> {
4671        let Some(principal) = principal else {
4672            return self.require(permission);
4673        };
4674        let resolved;
4675        let principal = if self.auth_state.require_auth() || principal.user_id != 0 {
4676            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
4677                .ok_or(MongrelError::AuthRequired)?;
4678            &resolved
4679        } else {
4680            principal
4681        };
4682        #[cfg(test)]
4683        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
4684        if principal.has_permission(permission) {
4685            Ok(())
4686        } else {
4687            Err(MongrelError::PermissionDenied {
4688                required: permission.clone(),
4689                principal: principal.username.clone(),
4690            })
4691        }
4692    }
4693
4694    /// Recheck the exact operation principal while the caller holds the
4695    /// security gate. This deliberately performs no refresh or nested gate
4696    /// acquisition.
4697    fn require_exact_principal_current(
4698        &self,
4699        principal: Option<&crate::auth::Principal>,
4700        permission: &crate::auth::Permission,
4701    ) -> Result<()> {
4702        let catalog = self.catalog.read();
4703        if !catalog.require_auth {
4704            return Ok(());
4705        }
4706        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
4707        let current = Self::resolve_bound_principal_from_catalog(&catalog, supplied)
4708            .ok_or(MongrelError::AuthRequired)?;
4709        if current.has_permission(permission) {
4710            Ok(())
4711        } else {
4712            Err(MongrelError::PermissionDenied {
4713                required: permission.clone(),
4714                principal: current.username,
4715            })
4716        }
4717    }
4718
4719    pub(crate) fn with_exact_principal_current<T, F>(
4720        &self,
4721        principal: Option<&crate::auth::Principal>,
4722        permission: &crate::auth::Permission,
4723        operation: F,
4724    ) -> Result<T>
4725    where
4726        F: FnOnce() -> Result<T>,
4727    {
4728        let _security = self.security_coordinator.gate.read();
4729        self.require_exact_principal_current(principal, permission)?;
4730        operation()
4731    }
4732
4733    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
4734        self.principal.read().clone()
4735    }
4736
4737    #[cfg(test)]
4738    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
4739        *self.principal.write() = principal.clone();
4740        self.auth_state.set_principal(principal);
4741    }
4742
4743    pub fn require_columns_for(
4744        &self,
4745        table: &str,
4746        operation: crate::auth::ColumnOperation,
4747        column_ids: &[u16],
4748        principal: Option<&crate::auth::Principal>,
4749    ) -> Result<()> {
4750        if principal.is_none() && !self.auth_state.require_auth() {
4751            return Ok(());
4752        }
4753        let cached = self.principal.read().clone();
4754        let principal = principal.or(cached.as_ref());
4755        let Some(principal) = principal else {
4756            let permission = match operation {
4757                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
4758                    table: table.to_string(),
4759                },
4760                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
4761                    table: table.to_string(),
4762                },
4763                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
4764                    table: table.to_string(),
4765                },
4766            };
4767            return self.require(&permission);
4768        };
4769        let catalog = self.catalog.read();
4770        let resolved;
4771        let principal = if catalog.require_auth || principal.user_id != 0 {
4772            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
4773                .ok_or(MongrelError::AuthRequired)?;
4774            &resolved
4775        } else {
4776            principal
4777        };
4778        let schema = &catalog
4779            .live(table)
4780            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
4781            .schema;
4782        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
4783    }
4784
4785    fn require_columns_for_principal(
4786        table: &str,
4787        schema: &Schema,
4788        operation: crate::auth::ColumnOperation,
4789        column_ids: &[u16],
4790        principal: &crate::auth::Principal,
4791    ) -> Result<()> {
4792        #[cfg(test)]
4793        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
4794        match principal.column_access(table, operation) {
4795            crate::auth::ColumnAccess::All => Ok(()),
4796            crate::auth::ColumnAccess::Columns(allowed) => {
4797                let denied = column_ids.iter().find_map(|column_id| {
4798                    schema
4799                        .columns
4800                        .iter()
4801                        .find(|column| column.id == *column_id)
4802                        .filter(|column| !allowed.contains(&column.name))
4803                });
4804                if denied.is_none() {
4805                    Ok(())
4806                } else {
4807                    Err(MongrelError::PermissionDenied {
4808                        required: match operation {
4809                            crate::auth::ColumnOperation::Select => {
4810                                crate::auth::Permission::SelectColumns {
4811                                    table: table.to_string(),
4812                                    columns: denied
4813                                        .into_iter()
4814                                        .map(|column| column.name.clone())
4815                                        .collect(),
4816                                }
4817                            }
4818                            crate::auth::ColumnOperation::Insert => {
4819                                crate::auth::Permission::InsertColumns {
4820                                    table: table.to_string(),
4821                                    columns: denied
4822                                        .into_iter()
4823                                        .map(|column| column.name.clone())
4824                                        .collect(),
4825                                }
4826                            }
4827                            crate::auth::ColumnOperation::Update => {
4828                                crate::auth::Permission::UpdateColumns {
4829                                    table: table.to_string(),
4830                                    columns: denied
4831                                        .into_iter()
4832                                        .map(|column| column.name.clone())
4833                                        .collect(),
4834                                }
4835                            }
4836                        },
4837                        principal: principal.username.clone(),
4838                    })
4839                }
4840            }
4841            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
4842                required: match operation {
4843                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
4844                        table: table.to_string(),
4845                    },
4846                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
4847                        table: table.to_string(),
4848                    },
4849                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
4850                        table: table.to_string(),
4851                    },
4852                },
4853                principal: principal.username.clone(),
4854            }),
4855        }
4856    }
4857
4858    pub fn select_column_ids_for(
4859        &self,
4860        table: &str,
4861        principal: Option<&crate::auth::Principal>,
4862    ) -> Result<Vec<u16>> {
4863        let catalog = self.catalog.read();
4864        let columns = catalog
4865            .live(table)
4866            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
4867            .schema
4868            .columns
4869            .iter()
4870            .map(|column| (column.id, column.name.clone()))
4871            .collect::<Vec<_>>();
4872        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
4873        drop(catalog);
4874        let Some(principal) = principal.as_ref() else {
4875            self.require(&crate::auth::Permission::Select {
4876                table: table.to_string(),
4877            })?;
4878            return Ok(columns.iter().map(|(id, _)| *id).collect());
4879        };
4880        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
4881            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
4882            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
4883                .iter()
4884                .filter(|(_, name)| allowed.contains(name))
4885                .map(|(id, _)| *id)
4886                .collect()),
4887            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
4888                required: crate::auth::Permission::Select {
4889                    table: table.to_string(),
4890                },
4891                principal: principal.username.clone(),
4892            }),
4893        }
4894    }
4895
4896    pub fn secure_rows_for(
4897        &self,
4898        table: &str,
4899        rows: Vec<crate::memtable::Row>,
4900        principal: Option<&crate::auth::Principal>,
4901    ) -> Result<Vec<crate::memtable::Row>> {
4902        self.secure_rows_for_with_context(table, rows, principal, None)
4903    }
4904
4905    pub fn secure_rows_for_with_context(
4906        &self,
4907        table: &str,
4908        rows: Vec<crate::memtable::Row>,
4909        principal: Option<&crate::auth::Principal>,
4910        context: Option<&crate::query::AiExecutionContext>,
4911    ) -> Result<Vec<crate::memtable::Row>> {
4912        let (security, principal) = {
4913            let catalog = self.catalog.read();
4914            (
4915                catalog.security.clone(),
4916                self.principal_for_authorized_read(&catalog, principal, false)?,
4917            )
4918        };
4919        if !security.table_has_security(table) {
4920            return Ok(rows);
4921        }
4922        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
4923        let mut output = Vec::new();
4924        for mut row in rows {
4925            if let Some(context) = context {
4926                context.consume(1)?;
4927            }
4928            if security.row_allowed(
4929                table,
4930                crate::security::PolicyCommand::Select,
4931                &row,
4932                principal,
4933                false,
4934            ) {
4935                security.apply_masks(table, &mut row, principal);
4936                output.push(row);
4937            }
4938        }
4939        Ok(output)
4940    }
4941
4942    /// Apply column masks to already RLS-authorized scored hits without a
4943    /// second row gather or policy evaluation.
4944    pub fn mask_search_hits_for(
4945        &self,
4946        table: &str,
4947        hits: &mut [crate::query::SearchHit],
4948        principal: Option<&crate::auth::Principal>,
4949    ) -> Result<()> {
4950        let (security, principal) = {
4951            let catalog = self.catalog.read();
4952            (
4953                catalog.security.clone(),
4954                self.principal_for_authorized_read(&catalog, principal, false)?,
4955            )
4956        };
4957        if !security.table_has_security(table) {
4958            return Ok(());
4959        }
4960        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
4961        for hit in hits {
4962            security.apply_masks_to_cells(table, &mut hit.cells, principal);
4963        }
4964        Ok(())
4965    }
4966
4967    /// Apply masks to rows already admitted by candidate-aware RLS.
4968    pub fn mask_rows_for(
4969        &self,
4970        table: &str,
4971        rows: &mut [crate::memtable::Row],
4972        principal: Option<&crate::auth::Principal>,
4973    ) -> Result<()> {
4974        let (security, principal) = {
4975            let catalog = self.catalog.read();
4976            (
4977                catalog.security.clone(),
4978                self.principal_for_authorized_read(&catalog, principal, false)?,
4979            )
4980        };
4981        if !security.table_has_security(table) {
4982            return Ok(());
4983        }
4984        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
4985        for row in rows {
4986            security.apply_masks(table, row, principal);
4987        }
4988        Ok(())
4989    }
4990
4991    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
4992    pub fn authorized_candidate_ids_for(
4993        &self,
4994        table: &str,
4995        principal: Option<&crate::auth::Principal>,
4996    ) -> Result<Option<std::collections::HashSet<RowId>>> {
4997        Ok(self
4998            .authorized_read_snapshot(table, principal)?
4999            .allowed_row_ids)
5000    }
5001
5002    fn allowed_row_ids_locked(
5003        &self,
5004        table_name: &str,
5005        table: &Table,
5006        table_snapshot: Snapshot,
5007        security_state: (&crate::security::SecurityCatalog, u64),
5008        principal: Option<&crate::auth::Principal>,
5009        context: Option<&crate::query::AiExecutionContext>,
5010    ) -> Result<Option<Arc<HashSet<RowId>>>> {
5011        let (security, security_version) = security_state;
5012        if !security.rls_enabled(table_name) {
5013            return Ok(None);
5014        }
5015        let authorization_started = std::time::Instant::now();
5016        let principal = principal.ok_or(MongrelError::AuthRequired)?;
5017        let mut roles = principal.roles.clone();
5018        roles.sort_unstable();
5019        let principal_key = format!(
5020            "{}:{}:{}:{}:{roles:?}",
5021            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
5022        );
5023        let cache_key = (
5024            table_name.to_string(),
5025            table.data_generation(),
5026            security_version,
5027            principal_key,
5028        );
5029        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
5030            crate::trace::QueryTrace::record(|trace| {
5031                trace.rls_cache_hit = true;
5032                trace.authorization_nanos = trace
5033                    .authorization_nanos
5034                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5035            });
5036            return Ok(Some(allowed));
5037        }
5038        if let Some(context) = context {
5039            context.checkpoint()?;
5040        }
5041        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
5042        let started = std::time::Instant::now();
5043        let rows = table.visible_rows(table_snapshot)?;
5044        let rows_evaluated = rows.len() as u64;
5045        let mut allowed = HashSet::new();
5046        for chunk in rows.chunks(256) {
5047            if let Some(context) = context {
5048                context.consume(chunk.len())?;
5049            }
5050            allowed.extend(chunk.iter().filter_map(|row| {
5051                security
5052                    .row_allowed(
5053                        table_name,
5054                        crate::security::PolicyCommand::Select,
5055                        row,
5056                        principal,
5057                        false,
5058                    )
5059                    .then_some(row.row_id)
5060            }));
5061        }
5062        let allowed = Arc::new(allowed);
5063        let mut cache = self.rls_cache.lock();
5064        cache.build_nanos = cache
5065            .build_nanos
5066            .saturating_add(started.elapsed().as_nanos() as u64);
5067        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
5068        cache.insert(cache_key, Arc::clone(&allowed));
5069        crate::trace::QueryTrace::record(|trace| {
5070            trace.rls_rows_evaluated = trace
5071                .rls_rows_evaluated
5072                .saturating_add(rows_evaluated as usize);
5073            trace.authorization_nanos = trace
5074                .authorization_nanos
5075                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5076        });
5077        Ok(Some(allowed))
5078    }
5079
5080    fn principal_for_authorized_read(
5081        &self,
5082        catalog: &Catalog,
5083        principal: Option<&crate::auth::Principal>,
5084        catalog_bound: bool,
5085    ) -> Result<Option<crate::auth::Principal>> {
5086        let principal = principal.cloned().or_else(|| self.principal.read().clone());
5087        let Some(principal) = principal else {
5088            return Ok(None);
5089        };
5090        if catalog.require_auth || catalog_bound || principal.user_id != 0 {
5091            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
5092                .map(Some)
5093                .ok_or(MongrelError::AuthRequired);
5094        }
5095        Ok(Some(principal))
5096    }
5097
5098    /// Run authorization, candidate generation, ranking, and materialization
5099    /// while holding one table generation. Security changes cause a bounded
5100    /// retry before any result is published.
5101    pub fn with_authorized_read<T, F>(
5102        &self,
5103        table_name: &str,
5104        principal: Option<&crate::auth::Principal>,
5105        catalog_bound: bool,
5106        read: F,
5107    ) -> Result<T>
5108    where
5109        F: FnMut(
5110            &mut Table,
5111            Snapshot,
5112            Option<&HashSet<RowId>>,
5113            Option<&crate::auth::Principal>,
5114        ) -> Result<T>,
5115    {
5116        self.with_authorized_read_context(
5117            table_name,
5118            principal,
5119            catalog_bound,
5120            None,
5121            None,
5122            None,
5123            read,
5124        )
5125    }
5126
5127    #[allow(clippy::too_many_arguments)]
5128    pub fn with_authorized_read_context<T, F>(
5129        &self,
5130        table_name: &str,
5131        principal: Option<&crate::auth::Principal>,
5132        catalog_bound: bool,
5133        authorization: Option<&ReadAuthorization>,
5134        context: Option<&crate::query::AiExecutionContext>,
5135        snapshot_override: Option<Snapshot>,
5136        read: F,
5137    ) -> Result<T>
5138    where
5139        F: FnMut(
5140            &mut Table,
5141            Snapshot,
5142            Option<&HashSet<RowId>>,
5143            Option<&crate::auth::Principal>,
5144        ) -> Result<T>,
5145    {
5146        self.with_authorized_read_context_stamped(
5147            table_name,
5148            principal,
5149            catalog_bound,
5150            authorization,
5151            context,
5152            snapshot_override,
5153            read,
5154        )
5155        .map(|(result, _)| result)
5156    }
5157
5158    #[allow(clippy::too_many_arguments)]
5159    pub fn with_authorized_read_context_stamped<T, F>(
5160        &self,
5161        table_name: &str,
5162        principal: Option<&crate::auth::Principal>,
5163        catalog_bound: bool,
5164        authorization: Option<&ReadAuthorization>,
5165        context: Option<&crate::query::AiExecutionContext>,
5166        snapshot_override: Option<Snapshot>,
5167        mut read: F,
5168    ) -> Result<(T, AuthorizedReadStamp)>
5169    where
5170        F: FnMut(
5171            &mut Table,
5172            Snapshot,
5173            Option<&HashSet<RowId>>,
5174            Option<&crate::auth::Principal>,
5175        ) -> Result<T>,
5176    {
5177        if principal.is_none() && self.principal.read().is_some() {
5178            self.refresh_principal()?;
5179        }
5180        const RETRIES: usize = 3;
5181        let handle = self.table(table_name)?;
5182        for attempt in 0..RETRIES {
5183            crate::trace::QueryTrace::record(|trace| {
5184                trace.authorization_retries = attempt;
5185            });
5186            let (security, security_version, effective_principal) = {
5187                let catalog = self.catalog.read();
5188                (
5189                    catalog.security.clone(),
5190                    catalog.security_version,
5191                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
5192                )
5193            };
5194            if let Some(authorization) = authorization {
5195                for permission in &authorization.permissions {
5196                    self.require_for(effective_principal.as_ref(), permission)?;
5197                }
5198                self.require_columns_for(
5199                    table_name,
5200                    authorization.operation,
5201                    &authorization.columns,
5202                    effective_principal.as_ref(),
5203                )?;
5204            }
5205            let result = {
5206                let mut table = lock_table_with_context(&handle, context)?;
5207                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
5208                let allowed = self.allowed_row_ids_locked(
5209                    table_name,
5210                    &table,
5211                    snapshot,
5212                    (&security, security_version),
5213                    effective_principal.as_ref(),
5214                    context,
5215                )?;
5216                let stamp = AuthorizedReadStamp {
5217                    table_id: table.table_id(),
5218                    schema_id: table.schema().schema_id,
5219                    data_generation: table.data_generation(),
5220                    security_version,
5221                    snapshot,
5222                };
5223                let result = read(
5224                    &mut table,
5225                    snapshot,
5226                    allowed.as_deref(),
5227                    effective_principal.as_ref(),
5228                )?;
5229                (result, stamp)
5230            };
5231            if let Some(context) = context {
5232                context.checkpoint()?;
5233            }
5234            if self.catalog.read().security_version == security_version {
5235                return Ok(result);
5236            }
5237            if attempt + 1 == RETRIES {
5238                return Err(MongrelError::Conflict(
5239                    "security policy changed during scored read".into(),
5240                ));
5241            }
5242        }
5243        Err(MongrelError::Conflict(
5244            "authorization retry loop exhausted".into(),
5245        ))
5246    }
5247
5248    fn with_authorized_aggregate_table<T, F>(
5249        &self,
5250        table_name: &str,
5251        columns: &[u16],
5252        principal: Option<&crate::auth::Principal>,
5253        catalog_bound: bool,
5254        allow_table_security: bool,
5255        mut aggregate: F,
5256    ) -> Result<T>
5257    where
5258        F: FnMut(
5259            &mut Table,
5260            Option<&crate::security::CandidateAuthorization<'_>>,
5261            Option<&crate::auth::Principal>,
5262            u64,
5263        ) -> Result<T>,
5264    {
5265        if principal.is_none() && self.principal.read().is_some() {
5266            self.refresh_principal()?;
5267        }
5268        const RETRIES: usize = 3;
5269        let handle = self.table(table_name)?;
5270        for attempt in 0..RETRIES {
5271            let (security, security_version, effective_principal) = {
5272                let catalog = self.catalog.read();
5273                (
5274                    catalog.security.clone(),
5275                    catalog.security_version,
5276                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
5277                )
5278            };
5279            self.require_columns_for(
5280                table_name,
5281                crate::auth::ColumnOperation::Select,
5282                columns,
5283                effective_principal.as_ref(),
5284            )?;
5285            if !allow_table_security && security.table_has_security(table_name) {
5286                return Err(MongrelError::InvalidArgument(
5287                    "incremental aggregate is unsupported while RLS or column masks are active"
5288                        .into(),
5289                ));
5290            }
5291            let result = {
5292                let mut table = handle.lock();
5293                let authorization = if security.rls_enabled(table_name) {
5294                    Some(crate::security::CandidateAuthorization {
5295                        table: table_name,
5296                        security: &security,
5297                        principal: effective_principal
5298                            .as_ref()
5299                            .ok_or(MongrelError::AuthRequired)?,
5300                    })
5301                } else {
5302                    None
5303                };
5304                aggregate(
5305                    &mut table,
5306                    authorization.as_ref(),
5307                    effective_principal.as_ref(),
5308                    security_version,
5309                )?
5310            };
5311            if self.catalog.read().security_version == security_version {
5312                return Ok(result);
5313            }
5314            if attempt + 1 == RETRIES {
5315                return Err(MongrelError::Conflict(
5316                    "security policy changed during aggregate read".into(),
5317                ));
5318            }
5319        }
5320        Err(MongrelError::Conflict(
5321            "aggregate authorization retry loop exhausted".into(),
5322        ))
5323    }
5324
5325    /// Scored-read authorization that evaluates RLS only for approximate
5326    /// candidates. This avoids a full-table policy scan on cache misses while
5327    /// preserving one table generation and security-version retry.
5328    pub fn with_authorized_scored_read_context<T, F>(
5329        &self,
5330        table_name: &str,
5331        principal: Option<&crate::auth::Principal>,
5332        catalog_bound: bool,
5333        authorization: Option<&ReadAuthorization>,
5334        context: Option<&crate::query::AiExecutionContext>,
5335        mut read: F,
5336    ) -> Result<T>
5337    where
5338        F: FnMut(
5339            &mut Table,
5340            Snapshot,
5341            Option<&crate::security::CandidateAuthorization<'_>>,
5342            Option<&crate::auth::Principal>,
5343        ) -> Result<T>,
5344    {
5345        self.with_authorized_scored_read_context_at(
5346            table_name,
5347            principal,
5348            catalog_bound,
5349            authorization,
5350            context,
5351            None,
5352            |table, snapshot, authorization, principal| {
5353                let mut table = table.clone();
5354                read(&mut table, snapshot, authorization, principal)
5355            },
5356        )
5357    }
5358
5359    #[allow(clippy::too_many_arguments)]
5360    pub fn with_authorized_scored_read_context_at<T, F>(
5361        &self,
5362        table_name: &str,
5363        principal: Option<&crate::auth::Principal>,
5364        catalog_bound: bool,
5365        authorization: Option<&ReadAuthorization>,
5366        context: Option<&crate::query::AiExecutionContext>,
5367        snapshot_override: Option<Snapshot>,
5368        read: F,
5369    ) -> Result<T>
5370    where
5371        F: FnMut(
5372            &Table,
5373            Snapshot,
5374            Option<&crate::security::CandidateAuthorization<'_>>,
5375            Option<&crate::auth::Principal>,
5376        ) -> Result<T>,
5377    {
5378        self.with_authorized_scored_read_context_at_stamped(
5379            table_name,
5380            principal,
5381            catalog_bound,
5382            authorization,
5383            context,
5384            snapshot_override,
5385            read,
5386        )
5387        .map(|(result, _)| result)
5388    }
5389
5390    #[allow(clippy::too_many_arguments)]
5391    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
5392        &self,
5393        table_name: &str,
5394        principal: Option<&crate::auth::Principal>,
5395        catalog_bound: bool,
5396        authorization: Option<&ReadAuthorization>,
5397        context: Option<&crate::query::AiExecutionContext>,
5398        snapshot_override: Option<Snapshot>,
5399        mut read: F,
5400    ) -> Result<(T, AuthorizedReadStamp)>
5401    where
5402        F: FnMut(
5403            &Table,
5404            Snapshot,
5405            Option<&crate::security::CandidateAuthorization<'_>>,
5406            Option<&crate::auth::Principal>,
5407        ) -> Result<T>,
5408    {
5409        if principal.is_none() && self.principal.read().is_some() {
5410            self.refresh_principal()?;
5411        }
5412        const RETRIES: usize = 3;
5413        let handle = self.table(table_name)?;
5414        for attempt in 0..RETRIES {
5415            if let Some(context) = context {
5416                context.checkpoint()?;
5417            }
5418            crate::trace::QueryTrace::record(|trace| {
5419                trace.authorization_retries = attempt;
5420            });
5421            let (security, security_version, effective_principal) = {
5422                let catalog = self.catalog.read();
5423                (
5424                    catalog.security.clone(),
5425                    catalog.security_version,
5426                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
5427                )
5428            };
5429            if let Some(authorization) = authorization {
5430                for permission in &authorization.permissions {
5431                    self.require_for(effective_principal.as_ref(), permission)?;
5432                }
5433                self.require_columns_for(
5434                    table_name,
5435                    authorization.operation,
5436                    &authorization.columns,
5437                    effective_principal.as_ref(),
5438                )?;
5439            }
5440            let result = {
5441                let (table, snapshot, _snapshot_guard, _run_pins) =
5442                    self.scored_read_generation(&handle, context, snapshot_override)?;
5443                let candidate_authorization = if security.rls_enabled(table_name) {
5444                    Some(crate::security::CandidateAuthorization {
5445                        table: table_name,
5446                        security: &security,
5447                        principal: effective_principal
5448                            .as_ref()
5449                            .ok_or(MongrelError::AuthRequired)?,
5450                    })
5451                } else {
5452                    None
5453                };
5454                let stamp = AuthorizedReadStamp {
5455                    table_id: table.table_id(),
5456                    schema_id: table.schema().schema_id,
5457                    data_generation: table.data_generation(),
5458                    security_version,
5459                    snapshot,
5460                };
5461                let result = read(
5462                    table.as_ref(),
5463                    snapshot,
5464                    candidate_authorization.as_ref(),
5465                    effective_principal.as_ref(),
5466                )?;
5467                (result, stamp)
5468            };
5469            if let Some(context) = context {
5470                context.checkpoint()?;
5471            }
5472            if self.catalog.read().security_version == security_version {
5473                return Ok(result);
5474            }
5475            if attempt + 1 == RETRIES {
5476                return Err(MongrelError::Conflict(
5477                    "security policy changed during scored read".into(),
5478                ));
5479            }
5480        }
5481        Err(MongrelError::Conflict(
5482            "scored-read authorization retry loop exhausted".into(),
5483        ))
5484    }
5485
5486    fn scored_read_generation(
5487        &self,
5488        handle: &TableHandle,
5489        context: Option<&crate::query::AiExecutionContext>,
5490        snapshot_override: Option<Snapshot>,
5491    ) -> Result<(
5492        Arc<TableReadGeneration>,
5493        Snapshot,
5494        crate::retention::OwnedSnapshotGuard,
5495        RunPins,
5496    )> {
5497        let mut table = if let Some(context) = context {
5498            loop {
5499                context.checkpoint()?;
5500                let wait = context
5501                    .remaining_duration()
5502                    .unwrap_or(std::time::Duration::from_millis(5))
5503                    .min(std::time::Duration::from_millis(5));
5504                if let Some(table) = handle.try_lock_for(wait) {
5505                    break table;
5506                }
5507            }
5508        } else {
5509            handle.lock()
5510        };
5511        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
5512            self.snapshot_at_owned(snapshot.epoch)?
5513        } else {
5514            let snapshot = table.snapshot();
5515            let guard = self.snapshots.register_owned(snapshot.epoch);
5516            (snapshot, guard)
5517        };
5518        let table_id = table.table_id();
5519        let run_keys: Vec<_> = table
5520            .active_run_ids()
5521            .map(|run_id| (table_id, run_id))
5522            .collect();
5523        let generation = handle
5524            .generation_metrics
5525            .activate(table.clone_read_generation()?);
5526        let run_pins = self.pin_runs(&run_keys);
5527        Ok((generation, snapshot, snapshot_guard, run_pins))
5528    }
5529
5530    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
5531        let mut pins = self.backup_pins.lock();
5532        for run in runs {
5533            *pins.entry(*run).or_insert(0) += 1;
5534        }
5535        drop(pins);
5536        RunPins {
5537            pins: Arc::clone(&self.backup_pins),
5538            runs: runs.to_vec(),
5539        }
5540    }
5541
5542    /// Execute a native conjunctive read with the database principal's row
5543    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
5544    /// policy-unaware; language bindings must use this boundary for reads.
5545    pub fn query_for_current_principal(
5546        &self,
5547        table_name: &str,
5548        query: &crate::query::Query,
5549        projection: Option<&[u16]>,
5550    ) -> Result<Vec<crate::memtable::Row>> {
5551        let condition_columns = crate::query::condition_columns(&query.conditions);
5552        self.with_authorized_read(
5553            table_name,
5554            None,
5555            true,
5556            |table, snapshot, allowed, principal| {
5557                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5558                self.require_columns_for(
5559                    table_name,
5560                    crate::auth::ColumnOperation::Select,
5561                    &condition_columns,
5562                    principal,
5563                )?;
5564                if let Some(projection) = projection {
5565                    self.require_columns_for(
5566                        table_name,
5567                        crate::auth::ColumnOperation::Select,
5568                        projection,
5569                        principal,
5570                    )?;
5571                }
5572                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
5573                let projection =
5574                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
5575                for row in &mut rows {
5576                    row.columns.retain(|column, _| {
5577                        allowed_columns.contains(column)
5578                            && projection
5579                                .as_ref()
5580                                .is_none_or(|projection| projection.contains(column))
5581                    });
5582                }
5583                self.secure_rows_for(table_name, rows, principal)
5584            },
5585        )
5586    }
5587
5588    /// Execute a secured native read with cooperative cancellation across
5589    /// authorization, candidate generation, materialization, masking, and
5590    /// projection.
5591    pub fn query_for_current_principal_controlled(
5592        &self,
5593        table_name: &str,
5594        query: &crate::query::Query,
5595        projection: Option<&[u16]>,
5596        control: &crate::ExecutionControl,
5597    ) -> Result<Vec<crate::memtable::Row>> {
5598        self.query_for_principal_controlled(table_name, query, projection, None, true, control)
5599    }
5600
5601    fn query_for_principal_controlled(
5602        &self,
5603        table_name: &str,
5604        query: &crate::query::Query,
5605        projection: Option<&[u16]>,
5606        principal: Option<&crate::auth::Principal>,
5607        catalog_bound: bool,
5608        control: &crate::ExecutionControl,
5609    ) -> Result<Vec<crate::memtable::Row>> {
5610        control.checkpoint()?;
5611        let context = crate::query::AiExecutionContext::with_control(
5612            control.clone(),
5613            usize::MAX,
5614            crate::query::MAX_FUSED_CANDIDATES,
5615        );
5616        let condition_columns = crate::query::condition_columns(&query.conditions);
5617        self.with_authorized_read_context(
5618            table_name,
5619            principal,
5620            catalog_bound,
5621            None,
5622            Some(&context),
5623            None,
5624            |table, snapshot, allowed, principal| {
5625                control.checkpoint()?;
5626                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5627                self.require_columns_for(
5628                    table_name,
5629                    crate::auth::ColumnOperation::Select,
5630                    &condition_columns,
5631                    principal,
5632                )?;
5633                if let Some(projection) = projection {
5634                    self.require_columns_for(
5635                        table_name,
5636                        crate::auth::ColumnOperation::Select,
5637                        projection,
5638                        principal,
5639                    )?;
5640                }
5641                let rows =
5642                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
5643                let projection =
5644                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
5645                let mut projected = Vec::with_capacity(rows.len());
5646                for (index, mut row) in rows.into_iter().enumerate() {
5647                    if index & 255 == 0 {
5648                        control.checkpoint()?;
5649                    }
5650                    row.columns.retain(|column, _| {
5651                        allowed_columns.contains(column)
5652                            && projection
5653                                .as_ref()
5654                                .is_none_or(|projection| projection.contains(column))
5655                    });
5656                    projected.push(row);
5657                }
5658                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
5659            },
5660        )
5661    }
5662
5663    /// Reservoir aggregate with column grants, RLS, masks, and security-version
5664    /// retry applied at the database boundary.
5665    pub fn approx_aggregate_for_current_principal(
5666        &self,
5667        table_name: &str,
5668        conditions: &[crate::query::Condition],
5669        column: Option<u16>,
5670        agg: crate::engine::ApproxAgg,
5671        z: f64,
5672    ) -> Result<Option<crate::engine::ApproxResult>> {
5673        if !z.is_finite() || z <= 0.0 {
5674            return Err(MongrelError::InvalidArgument(
5675                "z must be finite and > 0".into(),
5676            ));
5677        }
5678        let mut columns = crate::query::condition_columns(conditions);
5679        columns.extend(column);
5680        columns.sort_unstable();
5681        columns.dedup();
5682        self.with_authorized_aggregate_table(
5683            table_name,
5684            &columns,
5685            None,
5686            true,
5687            true,
5688            |table, authorization, _, _| {
5689                table.approx_aggregate_with_candidate_authorization(
5690                    conditions,
5691                    column,
5692                    agg,
5693                    z,
5694                    authorization,
5695                )
5696            },
5697        )
5698    }
5699
5700    /// Incremental aggregate over an append-only table. Active RLS or masks are
5701    /// rejected because the table-global delta cache cannot safely represent a
5702    /// secured row universe.
5703    pub fn incremental_aggregate_for_current_principal(
5704        &self,
5705        table_name: &str,
5706        conditions: &[crate::query::Condition],
5707        column: Option<u16>,
5708        agg: crate::engine::NativeAgg,
5709    ) -> Result<crate::engine::IncrementalAggResult> {
5710        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
5711    }
5712
5713    /// Incremental aggregate using an explicit request principal. A
5714    /// catalog-bound principal is re-resolved on every retry so live grants,
5715    /// revocations, RLS, and masks cannot reuse a stale cache entry.
5716    pub fn incremental_aggregate_for_principal(
5717        &self,
5718        table_name: &str,
5719        conditions: &[crate::query::Condition],
5720        column: Option<u16>,
5721        agg: crate::engine::NativeAgg,
5722        principal: Option<&crate::auth::Principal>,
5723        catalog_bound: bool,
5724    ) -> Result<crate::engine::IncrementalAggResult> {
5725        let mut columns = crate::query::condition_columns(conditions);
5726        columns.extend(column);
5727        columns.sort_unstable();
5728        columns.dedup();
5729        self.with_authorized_aggregate_table(
5730            table_name,
5731            &columns,
5732            principal,
5733            catalog_bound,
5734            false,
5735            |table, _, principal, security_version| {
5736                let cache_key = incremental_aggregate_cache_key(
5737                    table_name,
5738                    conditions,
5739                    column,
5740                    agg,
5741                    principal,
5742                    security_version,
5743                );
5744                table.aggregate_incremental(cache_key, conditions, column, agg)
5745            },
5746        )
5747    }
5748
5749    /// Read one row with the database principal's row policy, column grants,
5750    /// and masks applied.
5751    pub fn get_for_current_principal(
5752        &self,
5753        table_name: &str,
5754        row_id: RowId,
5755    ) -> Result<Option<crate::memtable::Row>> {
5756        self.with_authorized_read(
5757            table_name,
5758            None,
5759            true,
5760            |table, snapshot, allowed, principal| {
5761                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5762                let Some(row) = table.get(row_id, snapshot) else {
5763                    return Ok(None);
5764                };
5765                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
5766                    return Ok(None);
5767                }
5768                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
5769                if let Some(row) = rows.first_mut() {
5770                    row.columns
5771                        .retain(|column, _| allowed_columns.contains(column));
5772                }
5773                Ok(rows.pop())
5774            },
5775        )
5776    }
5777
5778    /// Run exact ANN reranking over only rows authorized for this database
5779    /// handle. The embedding column still requires normal column access.
5780    pub fn ann_rerank_for_current_principal(
5781        &self,
5782        table_name: &str,
5783        request: &crate::query::AnnRerankRequest,
5784    ) -> Result<Vec<crate::query::AnnRerankHit>> {
5785        self.with_authorized_scored_read_context_at(
5786            table_name,
5787            None,
5788            true,
5789            Some(&ReadAuthorization {
5790                operation: crate::auth::ColumnOperation::Select,
5791                columns: vec![request.column_id],
5792                permissions: Vec::new(),
5793            }),
5794            None,
5795            None,
5796            |table, snapshot, authorization, principal| {
5797                self.require_columns_for(
5798                    table_name,
5799                    crate::auth::ColumnOperation::Select,
5800                    &[request.column_id],
5801                    principal,
5802                )?;
5803                table.ann_rerank_at_with_candidate_authorization_on_generation(
5804                    request,
5805                    snapshot,
5806                    authorization,
5807                    None,
5808                )
5809            },
5810        )
5811    }
5812
5813    /// Run a hybrid scored search over only rows authorized for this database
5814    /// handle. Applies retriever column grants, RLS, and masks to the returned
5815    /// hits.
5816    pub fn search_for_current_principal(
5817        &self,
5818        table_name: &str,
5819        request: &crate::query::SearchRequest,
5820    ) -> Result<Vec<crate::query::SearchHit>> {
5821        let mut columns = crate::query::condition_columns(&request.must);
5822        for named in &request.retrievers {
5823            columns.push(named.retriever.column_id());
5824        }
5825        if let Some(crate::query::Rerank::ExactVector {
5826            embedding_column, ..
5827        }) = &request.rerank
5828        {
5829            columns.push(*embedding_column);
5830        }
5831        if let Some(proj) = &request.projection {
5832            columns.extend(proj);
5833        }
5834        columns.sort_unstable();
5835        columns.dedup();
5836        self.with_authorized_scored_read_context_at(
5837            table_name,
5838            None,
5839            true,
5840            Some(&ReadAuthorization {
5841                operation: crate::auth::ColumnOperation::Select,
5842                columns: columns.clone(),
5843                permissions: Vec::new(),
5844            }),
5845            None,
5846            None,
5847            |table, snapshot, authorization, principal| {
5848                self.require_columns_for(
5849                    table_name,
5850                    crate::auth::ColumnOperation::Select,
5851                    &columns,
5852                    principal,
5853                )?;
5854                let hits = table.search_at_with_candidate_authorization_on_generation(
5855                    request,
5856                    snapshot,
5857                    authorization,
5858                    None,
5859                )?;
5860                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5861                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
5862                for mut hit in hits {
5863                    let row = crate::memtable::Row {
5864                        row_id: hit.row_id,
5865                        committed_epoch: crate::Epoch(0),
5866                        columns: hit
5867                            .cells
5868                            .iter()
5869                            .cloned()
5870                            .collect::<std::collections::HashMap<u16, crate::Value>>(),
5871                        deleted: false,
5872                    };
5873                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
5874                    if let Some(mut row) = rows.pop() {
5875                        row.columns
5876                            .retain(|column, _| allowed_columns.contains(column));
5877                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
5878                        hit.cells.sort_by_key(|(column, _)| *column);
5879                        secured.push(hit);
5880                    }
5881                }
5882                Ok(secured)
5883            },
5884        )
5885    }
5886
5887    /// Capture one table snapshot and the security version used to authorize it.
5888    /// The caller must validate the returned version before publishing results.
5889    pub fn authorized_read_snapshot(
5890        &self,
5891        table: &str,
5892        principal: Option<&crate::auth::Principal>,
5893    ) -> Result<AuthorizedReadSnapshot> {
5894        let (security, security_version, effective_principal) = {
5895            let catalog = self.catalog.read();
5896            (
5897                catalog.security.clone(),
5898                catalog.security_version,
5899                self.principal_for_authorized_read(&catalog, principal, false)?,
5900            )
5901        };
5902        let handle = self.table(table)?;
5903        let (table_snapshot, data_generation, allowed_row_ids) = {
5904            let table_handle = handle.lock();
5905            let table_snapshot = table_handle.snapshot();
5906            let data_generation = table_handle.data_generation();
5907            let allowed = self.allowed_row_ids_locked(
5908                table,
5909                &table_handle,
5910                table_snapshot,
5911                (&security, security_version),
5912                effective_principal.as_ref(),
5913                None,
5914            )?;
5915            (
5916                table_snapshot,
5917                data_generation,
5918                allowed.map(|allowed| (*allowed).clone()),
5919            )
5920        };
5921        Ok(AuthorizedReadSnapshot {
5922            table: table.to_string(),
5923            table_snapshot,
5924            data_generation,
5925            security_version,
5926            allowed_row_ids,
5927        })
5928    }
5929
5930    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
5931        if self.catalog.read().security_version != snapshot.security_version {
5932            return false;
5933        }
5934        self.table(&snapshot.table)
5935            .ok()
5936            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
5937    }
5938
5939    pub fn rls_cache_stats(&self) -> RlsCacheStats {
5940        self.rls_cache.lock().stats()
5941    }
5942
5943    /// Read visible rows with column authorization, RLS, and masks applied.
5944    pub fn rows_for(
5945        &self,
5946        table: &str,
5947        principal: Option<&crate::auth::Principal>,
5948    ) -> Result<Vec<crate::memtable::Row>> {
5949        if principal.is_none() && self.principal.read().is_some() {
5950            self.refresh_principal()?;
5951        }
5952        let allowed = self.select_column_ids_for(table, principal)?;
5953        let handle = self.table(table)?;
5954        let rows = {
5955            let table = handle.lock();
5956            table.visible_rows(table.snapshot())?
5957        };
5958        let mut rows = self.secure_rows_for(table, rows, principal)?;
5959        for row in &mut rows {
5960            row.columns.retain(|column, _| allowed.contains(column));
5961        }
5962        Ok(rows)
5963    }
5964
5965    /// Historical rows use the current principal and security catalog against
5966    /// the row values visible at the requested snapshot.
5967    pub fn rows_at_epoch_for_current_principal(
5968        &self,
5969        table_name: &str,
5970        snapshot: Snapshot,
5971    ) -> Result<Vec<crate::memtable::Row>> {
5972        self.with_authorized_read_context(
5973            table_name,
5974            None,
5975            true,
5976            Some(&ReadAuthorization {
5977                operation: crate::auth::ColumnOperation::Select,
5978                columns: Vec::new(),
5979                permissions: Vec::new(),
5980            }),
5981            None,
5982            Some(snapshot),
5983            |table, snapshot, allowed, principal| {
5984                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
5985                let mut rows = table.visible_rows(snapshot)?;
5986                if let Some(allowed) = allowed {
5987                    rows.retain(|row| allowed.contains(&row.row_id));
5988                }
5989                rows = self.secure_rows_for(table_name, rows, principal)?;
5990                for row in &mut rows {
5991                    row.columns
5992                        .retain(|column, _| allowed_columns.contains(column));
5993                }
5994                Ok(rows)
5995            },
5996        )
5997    }
5998
5999    /// Count rows visible to a principal without bypassing RLS.
6000    pub fn count_for(
6001        &self,
6002        table: &str,
6003        principal: Option<&crate::auth::Principal>,
6004    ) -> Result<u64> {
6005        if principal.is_none() && self.principal.read().is_some() {
6006            self.refresh_principal()?;
6007        }
6008        self.select_column_ids_for(table, principal)?;
6009        if self.security_active_for(table) {
6010            Ok(self.rows_for(table, principal)?.len() as u64)
6011        } else {
6012            Ok(self.table(table)?.lock().count())
6013        }
6014    }
6015
6016    /// Authorize and write one native-API row for an explicit principal.
6017    pub fn put_for(
6018        &self,
6019        table: &str,
6020        mut cells: Vec<(u16, crate::memtable::Value)>,
6021        principal: Option<&crate::auth::Principal>,
6022    ) -> Result<RowId> {
6023        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
6024        self.require_columns_for(
6025            table,
6026            crate::auth::ColumnOperation::Insert,
6027            &columns,
6028            principal,
6029        )?;
6030        let handle = self.table(table)?;
6031        let mut table_handle = handle.lock();
6032        table_handle.fill_auto_inc(&mut cells)?;
6033        table_handle.apply_defaults(&mut cells)?;
6034        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
6035        row.columns.extend(cells.iter().cloned());
6036        self.check_row_policy_for(
6037            table,
6038            crate::security::PolicyCommand::Insert,
6039            &row,
6040            true,
6041            principal,
6042        )?;
6043        table_handle.put(cells)
6044    }
6045
6046    pub fn check_row_policy_for(
6047        &self,
6048        table: &str,
6049        command: crate::security::PolicyCommand,
6050        row: &crate::memtable::Row,
6051        check_new: bool,
6052        principal: Option<&crate::auth::Principal>,
6053    ) -> Result<()> {
6054        let security = self.catalog.read().security.clone();
6055        if !security.rls_enabled(table) {
6056            return Ok(());
6057        }
6058        let cached = self.principal.read().clone();
6059        let principal = principal
6060            .or(cached.as_ref())
6061            .ok_or(MongrelError::AuthRequired)?;
6062        if security.row_allowed(table, command, row, principal, check_new) {
6063            return Ok(());
6064        }
6065        let required = match command {
6066            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
6067                table: table.to_string(),
6068            },
6069            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
6070                table: table.to_string(),
6071            },
6072            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
6073                table: table.to_string(),
6074            },
6075            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
6076                crate::auth::Permission::Delete {
6077                    table: table.to_string(),
6078                }
6079            }
6080        };
6081        Err(MongrelError::PermissionDenied {
6082            required,
6083            principal: principal.username.clone(),
6084        })
6085    }
6086
6087    /// Durably create or replace a materialized-view definition after its
6088    /// physical table has been populated.
6089    pub fn set_materialized_view(
6090        &self,
6091        definition: crate::catalog::MaterializedViewEntry,
6092    ) -> Result<()> {
6093        self.set_materialized_view_with_epoch(definition)
6094            .map(|_| ())
6095    }
6096
6097    /// Durably create or replace a materialized-view definition and return its epoch.
6098    pub fn set_materialized_view_with_epoch(
6099        &self,
6100        definition: crate::catalog::MaterializedViewEntry,
6101    ) -> Result<Epoch> {
6102        use crate::wal::DdlOp;
6103        use std::sync::atomic::Ordering;
6104
6105        let command = crate::catalog_cmds::CatalogCommand::CreateMaterializedView {
6106            definition: definition.clone(),
6107        };
6108        self.require(&crate::catalog_cmds::required_permission(&command))?;
6109        if self.poisoned.load(Ordering::Relaxed) {
6110            return Err(MongrelError::Other(
6111                "database poisoned by fsync error".into(),
6112            ));
6113        }
6114        if definition.name.is_empty() || definition.query.trim().is_empty() {
6115            return Err(MongrelError::InvalidArgument(
6116                "materialized view name and query must not be empty".into(),
6117            ));
6118        }
6119        // S1A-004: admit the materialized-view replacement as one core
6120        // operation.
6121        let _operation = self.admit_operation()?;
6122        let _ddl = self.ddl_lock.lock();
6123        let _security_write = self.security_write()?;
6124        self.require(&crate::catalog_cmds::required_permission(&command))?;
6125        // S1F-001: the backing-table check validates pure against the current
6126        // catalog first so it surfaces before an epoch is consumed, exactly
6127        // like the legacy pre-bump lookup.
6128        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
6129        let table_id = self
6130            .catalog
6131            .read()
6132            .live(&definition.name)
6133            .ok_or_else(|| {
6134                MongrelError::NotFound(format!(
6135                    "materialized view table {:?} not found",
6136                    definition.name
6137                ))
6138            })?
6139            .table_id;
6140        let definition_json = DdlOp::encode_materialized_view(&definition)?;
6141        let _commit = self.commit_lock.lock();
6142        let epoch = self.epoch.bump_assigned();
6143        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6144        let txn_id = self.alloc_txn_id()?;
6145        let mut next_catalog = self.catalog.read().clone();
6146        self.apply_catalog_command_to(&mut next_catalog, command)?;
6147        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
6148        let commit_seq = {
6149            let mut wal = self.shared_wal.lock();
6150            let append: Result<u64> = (|| {
6151                wal.append(
6152                    txn_id,
6153                    table_id,
6154                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
6155                        name: definition.name.clone(),
6156                        definition_json,
6157                    }),
6158                )?;
6159                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
6160                wal.append_commit(txn_id, epoch, &[])
6161            })();
6162            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
6163        };
6164        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
6165
6166        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
6167        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
6168        Ok(epoch)
6169    }
6170
6171    /// The filesystem root this database was opened/created at.
6172    pub fn root(&self) -> &Path {
6173        self.durable_root.canonical_path()
6174    }
6175
6176    /// Open a descriptor-pinned view of this database root for durable
6177    /// extension state such as server idempotency receipts.
6178    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
6179        Arc::clone(&self.durable_root)
6180    }
6181
6182    /// Domain-separated authentication key for server idempotency state.
6183    /// Encrypted databases derive it from the in-memory KEK. Plain databases
6184    /// return `None`; their server persists a random key under the pinned root.
6185    #[cfg(feature = "encryption")]
6186    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
6187        self.kek
6188            .as_deref()
6189            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
6190    }
6191
6192    #[cfg(not(feature = "encryption"))]
6193    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
6194        None
6195    }
6196
6197    pub fn is_read_only_replica(&self) -> bool {
6198        self.read_only
6199    }
6200
6201    /// Reject reads whose backing state may require WAL recovery after a
6202    /// post-commit publication failure. Ordinary table/catalog state is made
6203    /// coherent before poison; file-backed external modules use this gate.
6204    pub fn ensure_consistent_read(&self) -> Result<()> {
6205        self.ensure_owner_process()?;
6206        if self.poisoned.load(Ordering::Relaxed) {
6207            return Err(MongrelError::Other(
6208                "database poisoned by post-commit failure; reopen required".into(),
6209            ));
6210        }
6211        Ok(())
6212    }
6213
6214    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
6215        self.replication_wal_retention_segments
6216            .store(segments, std::sync::atomic::Ordering::Relaxed);
6217    }
6218
6219    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
6220    /// direct table commits, compaction, and WAL append are quiesced while the
6221    /// file image is read. WAL records newer than manifests remain sufficient
6222    /// for recovery, so no flush or compaction is required.
6223    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
6224        let admin = crate::auth::Permission::Admin;
6225        self.require(&admin)?;
6226        // S1A-004: admit the bootstrap capture as one core operation.
6227        let _operation = self.admit_operation()?;
6228        let operation_principal = self.principal_snapshot();
6229        let _barrier = self.replication_barrier.write();
6230        let _ddl = self.ddl_lock.lock();
6231        let _security = self.security_coordinator.gate.read();
6232        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6233        let mut handles: Vec<_> = self
6234            .tables
6235            .read()
6236            .iter()
6237            .map(|(id, handle)| (*id, handle.clone()))
6238            .collect();
6239        handles.sort_by_key(|(id, _)| *id);
6240        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
6241        let _commit = self.commit_lock.lock();
6242        let mut wal = self.shared_wal.lock();
6243        wal.group_sync()?;
6244        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6245        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
6246        let epoch = records
6247            .iter()
6248            .filter_map(|record| match &record.op {
6249                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
6250                _ => None,
6251            })
6252            .max()
6253            .unwrap_or(0)
6254            .max(self.epoch.committed().0);
6255        let files = crate::replication::capture_files(&self.root)?;
6256        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
6257        drop(wal);
6258        Ok(crate::replication::ReplicationSnapshot::new(
6259            source_id, epoch, files,
6260        ))
6261    }
6262
6263    /// Create an online, directly-openable backup at `destination`.
6264    ///
6265    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
6266    /// mutable metadata, and pins the exact immutable runs named by the copied
6267    /// manifests. Writers resume while those runs stream into a sibling staging
6268    /// directory. A checksummed backup manifest is written last, then the stage
6269    /// is atomically renamed into place.
6270    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
6271        let control = crate::ExecutionControl::new(None);
6272        self.hot_backup_controlled(destination, &control, || true)
6273    }
6274
6275    pub(crate) fn hot_backup_to_durable_child(
6276        &self,
6277        parent: &crate::durable_file::DurableRoot,
6278        child: &Path,
6279        control: &crate::ExecutionControl,
6280    ) -> Result<crate::backup::BackupReport> {
6281        let mut components = child.components();
6282        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
6283            || components.next().is_some()
6284        {
6285            return Err(MongrelError::InvalidArgument(
6286                "durable backup child must be one relative path component".into(),
6287            ));
6288        }
6289        let destination_name = child.file_name().ok_or_else(|| {
6290            MongrelError::InvalidArgument("durable backup child has no filename".into())
6291        })?;
6292        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
6293        self.hot_backup_prepared(prepared, control, || true)
6294    }
6295
6296    /// Build a backup cooperatively, then invoke `before_publish` immediately
6297    /// before the staging directory is atomically renamed into place.
6298    #[doc(hidden)]
6299    pub fn hot_backup_controlled<F>(
6300        &self,
6301        destination: impl AsRef<Path>,
6302        control: &crate::ExecutionControl,
6303        before_publish: F,
6304    ) -> Result<crate::backup::BackupReport>
6305    where
6306        F: FnOnce() -> bool,
6307    {
6308        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
6309        self.hot_backup_prepared(prepared, control, before_publish)
6310    }
6311
6312    fn hot_backup_prepared<F>(
6313        &self,
6314        mut prepared: PreparedBackupDestination,
6315        control: &crate::ExecutionControl,
6316        before_publish: F,
6317    ) -> Result<crate::backup::BackupReport>
6318    where
6319        F: FnOnce() -> bool,
6320    {
6321        let admin = crate::auth::Permission::Admin;
6322        self.require(&admin)?;
6323        // S1A-004: admit the backup as one core operation; `shutdown()` waits
6324        // for it to drain instead of closing the core mid-copy.
6325        let _operation = self.admit_operation()?;
6326        let operation_principal = self.principal_snapshot();
6327        control.checkpoint()?;
6328        let destination = prepared.destination_path.clone();
6329        let mut before_publish = Some(before_publish);
6330
6331        let outcome = (|| {
6332            control.checkpoint()?;
6333            let barrier = self.replication_barrier.write();
6334            let ddl = self.ddl_lock.lock();
6335            let security = self.security_coordinator.gate.read();
6336            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6337            let mut handles: Vec<_> = self
6338                .tables
6339                .read()
6340                .iter()
6341                .map(|(id, handle)| (*id, handle.clone()))
6342                .collect();
6343            handles.sort_by_key(|(id, _)| *id);
6344            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
6345            let commit = self.commit_lock.lock();
6346            let mut wal = self.shared_wal.lock();
6347            wal.group_sync()?;
6348            let epoch = self.epoch.committed().0;
6349            let boundary_unix_nanos = current_unix_nanos();
6350
6351            let pin_nonce = std::time::SystemTime::now()
6352                .duration_since(std::time::UNIX_EPOCH)
6353                .unwrap_or_default()
6354                .as_nanos();
6355            let file_pin_root = self
6356                .root
6357                .join(META_DIR)
6358                .join("backup-pins")
6359                .join(format!("{}-{pin_nonce}", std::process::id()));
6360            std::fs::create_dir_all(&file_pin_root)?;
6361            let _file_pins = BackupFilePins {
6362                root: file_pin_root.clone(),
6363            };
6364            let mut run_files = Vec::new();
6365            for (index, (table_id, _)) in handles.iter().enumerate() {
6366                if index % 256 == 0 {
6367                    control.checkpoint()?;
6368                }
6369                let table = &table_guards[index];
6370                for (run_index, run) in table.run_refs().iter().enumerate() {
6371                    if run_index % 256 == 0 {
6372                        control.checkpoint()?;
6373                    }
6374                    let source = table.run_path(run.run_id as u64);
6375                    let relative = Path::new(TABLES_DIR)
6376                        .join(table_id.to_string())
6377                        .join(crate::engine::RUNS_DIR)
6378                        .join(format!("r-{}.sr", run.run_id));
6379                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
6380                    if std::fs::hard_link(&source, &pinned).is_err() {
6381                        crate::backup::copy_file_synced(&source, &pinned)?;
6382                    }
6383                    run_files.push(((*table_id, run.run_id), pinned, relative));
6384                }
6385            }
6386            crate::durable_file::sync_directory(&file_pin_root)?;
6387            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
6388            {
6389                let mut pins = self.backup_pins.lock();
6390                for key in &run_keys {
6391                    *pins.entry(*key).or_insert(0) += 1;
6392                }
6393            }
6394            let _run_pins = RunPins {
6395                pins: Arc::clone(&self.backup_pins),
6396                runs: run_keys,
6397            };
6398            // S1C-004: mirror the backup boundary into every table's unified
6399            // pin registry (PinSource::BackupPitr covers PITR base captures,
6400            // which route through this same path). Version GC must not
6401            // reclaim the boundary versions while their runs stream into the
6402            // stage, and diagnostics must expose the pin. The guards drop
6403            // with `_run_pins` when the backup finishes or fails.
6404            let _registry_pins: Vec<crate::retention::PinGuard> = table_guards
6405                .iter()
6406                .map(|table| {
6407                    table
6408                        .pin_registry()
6409                        .pin(crate::retention::PinSource::BackupPitr, Epoch(epoch))
6410                })
6411                .collect();
6412            let deferred: HashSet<_> = run_files
6413                .iter()
6414                .map(|(_, _, relative)| relative.clone())
6415                .collect();
6416            let mut copied = Vec::new();
6417            copy_backup_boundary(
6418                &self.root,
6419                prepared.stage.as_deref().ok_or_else(|| {
6420                    MongrelError::Other("backup staging root was already released".into())
6421                })?,
6422                &deferred,
6423                &mut copied,
6424                Some(control),
6425            )?;
6426
6427            drop(wal);
6428            drop(commit);
6429            drop(table_guards);
6430            drop(security);
6431            drop(ddl);
6432            drop(barrier);
6433
6434            if let Some(hook) = self.backup_hook.lock().as_ref() {
6435                hook();
6436            }
6437            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
6438                if index % 256 == 0 {
6439                    control.checkpoint()?;
6440                }
6441                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
6442                prepared
6443                    .stage
6444                    .as_deref()
6445                    .ok_or_else(|| {
6446                        MongrelError::Other("backup staging root was already released".into())
6447                    })?
6448                    .copy_new_from(&relative, &mut source)?;
6449                copied.push(relative);
6450            }
6451
6452            let manifest = crate::backup::BackupManifest::create_controlled_durable(
6453                prepared.stage.as_deref().ok_or_else(|| {
6454                    MongrelError::Other("backup staging root was already released".into())
6455                })?,
6456                epoch,
6457                &copied,
6458                control,
6459            )?;
6460            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
6461                MongrelError::Other("backup staging root was already released".into())
6462            })?)?;
6463            control.checkpoint()?;
6464            let publish = before_publish.take().ok_or_else(|| {
6465                MongrelError::Other("backup publication callback already consumed".into())
6466            })?;
6467            if !publish() {
6468                return Err(MongrelError::Cancelled);
6469            }
6470            let final_security = self.security_coordinator.gate.read();
6471            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6472            // Windows pins directories without delete sharing. Release the
6473            // stage handle before renaming that directory, while the parent
6474            // remains descriptor-pinned for the no-replace publication.
6475            drop(prepared.stage.take().ok_or_else(|| {
6476                MongrelError::Other("backup staging root was already released".into())
6477            })?);
6478            let published = std::cell::Cell::new(false);
6479            if let Err(error) = prepared.parent.rename_directory_new_with_after(
6480                Path::new(&prepared.stage_name),
6481                &prepared.parent,
6482                Path::new(&prepared.destination_name),
6483                || published.set(true),
6484            ) {
6485                if published.get() {
6486                    return Err(MongrelError::CommitOutcomeUnknown {
6487                        epoch,
6488                        message: format!("backup publication was not durable: {error}"),
6489                    });
6490                }
6491                return Err(error.into());
6492            }
6493            drop(final_security);
6494            Ok(crate::backup::BackupReport {
6495                destination,
6496                epoch,
6497                boundary_unix_nanos,
6498                files: manifest.files.len(),
6499                bytes: manifest.total_bytes(),
6500            })
6501        })();
6502
6503        if outcome.is_err() {
6504            drop(prepared.stage.take());
6505            let _ = prepared
6506                .parent
6507                .remove_directory_all(Path::new(&prepared.stage_name));
6508        }
6509        outcome
6510    }
6511
6512    /// Return complete committed transactions after `since_epoch`. A gap or a
6513    /// transaction backed by a spilled run requires a fresh bootstrap image.
6514    pub fn replication_batch_since(
6515        &self,
6516        since_epoch: u64,
6517    ) -> Result<crate::replication::ReplicationBatch> {
6518        use crate::wal::Op;
6519
6520        let admin = crate::auth::Permission::Admin;
6521        self.require(&admin)?;
6522        // S1A-004: admit the batch extraction as one core operation.
6523        let _operation = self.admit_operation()?;
6524        let operation_principal = self.principal_snapshot();
6525
6526        let mut wal = self.shared_wal.lock();
6527        wal.group_sync()?;
6528        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6529        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
6530        drop(wal);
6531
6532        let commits: HashMap<u64, u64> = records
6533            .iter()
6534            .filter_map(|record| match &record.op {
6535                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
6536                _ => None,
6537            })
6538            .collect();
6539        let earliest_epoch = commits.values().copied().min();
6540        let current_epoch = commits
6541            .values()
6542            .copied()
6543            .max()
6544            .unwrap_or(0)
6545            .max(self.epoch.committed().0);
6546        let selected: HashSet<u64> = commits
6547            .iter()
6548            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
6549            .collect();
6550        let retention_gap = since_epoch < current_epoch
6551            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
6552        let spilled = records.iter().any(|record| {
6553            selected.contains(&record.txn_id)
6554                && matches!(
6555                    &record.op,
6556                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
6557                )
6558        });
6559        let records = records
6560            .into_iter()
6561            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
6562            .filter(|record| selected.contains(&record.txn_id))
6563            .collect();
6564        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
6565        let batch = crate::replication::ReplicationBatch::complete_for_source(
6566            source_id,
6567            since_epoch,
6568            current_epoch,
6569            earliest_epoch,
6570            retention_gap,
6571            spilled,
6572            records,
6573        )?;
6574        if let Some(hook) = self.replication_hook.lock().as_ref() {
6575            hook();
6576        }
6577        let _security = self.security_coordinator.gate.read();
6578        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
6579        Ok(batch)
6580    }
6581
6582    /// Durably append a leader batch to a follower's local WAL and checkpoint
6583    /// its catalog metadata. Security changes apply to this live handle before
6584    /// success returns. The caller must reopen to mount new table state.
6585    pub fn append_replication_batch(
6586        &self,
6587        batch: &crate::replication::ReplicationBatch,
6588    ) -> Result<u64> {
6589        use crate::wal::Op;
6590
6591        if !self.read_only {
6592            return Err(MongrelError::InvalidArgument(
6593                "replication batches may only target a marked replica".into(),
6594            ));
6595        }
6596        // S1A-004: admit the follow-apply as one core operation.
6597        let _operation = self.admit_operation()?;
6598        let current = crate::replication::replica_epoch(&self.root)?;
6599        if batch.is_source_bound() {
6600            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
6601            if batch.source_id != source_id {
6602                return Err(MongrelError::Conflict(
6603                    "replication batch source does not match follower binding".into(),
6604                ));
6605            }
6606        }
6607        if batch.requires_snapshot {
6608            return Err(MongrelError::Conflict(
6609                "replication snapshot required for this batch".into(),
6610            ));
6611        }
6612        batch.validate_proof()?;
6613        if batch.from_epoch != current {
6614            if batch.from_epoch < current && batch.current_epoch == current {
6615                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6616                let _wal = self.shared_wal.lock();
6617                let existing: HashSet<(u64, u64)> =
6618                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
6619                        .into_iter()
6620                        .filter_map(|record| match record.op {
6621                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
6622                            _ => None,
6623                        })
6624                        .collect();
6625                let already_applied = batch.records.iter().all(|record| match &record.op {
6626                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
6627                    _ => true,
6628                });
6629                if already_applied {
6630                    return Ok(current);
6631                }
6632            }
6633            return Err(MongrelError::Conflict(format!(
6634                "replication batch starts at epoch {}, follower is at epoch {current}",
6635                batch.from_epoch
6636            )));
6637        }
6638        if batch.current_epoch < current {
6639            return Err(MongrelError::InvalidArgument(format!(
6640                "replication batch current epoch {} precedes follower epoch {current}",
6641                batch.current_epoch
6642            )));
6643        }
6644        let records = &batch.records;
6645        let mut commits = HashMap::new();
6646        let mut commit_epochs = HashSet::new();
6647        let mut commit_timestamps = HashMap::new();
6648        for record in records {
6649            match &record.op {
6650                Op::TxnCommit { epoch, added_runs } => {
6651                    if !added_runs.is_empty() {
6652                        return Err(MongrelError::Conflict(
6653                            "replication snapshot required for spilled-run transaction".into(),
6654                        ));
6655                    }
6656                    if commits.insert(record.txn_id, *epoch).is_some() {
6657                        return Err(MongrelError::InvalidArgument(format!(
6658                            "duplicate commit for replication transaction {}",
6659                            record.txn_id
6660                        )));
6661                    }
6662                    if *epoch <= current || *epoch > batch.current_epoch {
6663                        return Err(MongrelError::InvalidArgument(format!(
6664                            "replication commit epoch {epoch} is outside ({current}, {}]",
6665                            batch.current_epoch
6666                        )));
6667                    }
6668                    if !commit_epochs.insert(*epoch) {
6669                        return Err(MongrelError::InvalidArgument(format!(
6670                            "duplicate replication commit epoch {epoch}"
6671                        )));
6672                    }
6673                }
6674                Op::CommitTimestamp { unix_nanos } => {
6675                    commit_timestamps.insert(record.txn_id, *unix_nanos);
6676                }
6677                _ => {}
6678            }
6679        }
6680        for record in records {
6681            if record.txn_id == crate::wal::SYSTEM_TXN_ID
6682                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
6683            {
6684                return Err(MongrelError::InvalidArgument(
6685                    "replication batch contains a non-committed record".into(),
6686                ));
6687            }
6688            if !commits.contains_key(&record.txn_id) {
6689                return Err(MongrelError::InvalidArgument(format!(
6690                    "incomplete replication transaction {}",
6691                    record.txn_id
6692                )));
6693            }
6694        }
6695        let target_epoch = commits
6696            .values()
6697            .copied()
6698            .filter(|epoch| *epoch > current)
6699            .max()
6700            .unwrap_or(current);
6701        if target_epoch != batch.current_epoch {
6702            return Err(MongrelError::InvalidArgument(format!(
6703                "replication batch ends at epoch {target_epoch}, expected {}",
6704                batch.current_epoch
6705            )));
6706        }
6707        let mut selected: HashSet<u64> = commits
6708            .iter()
6709            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
6710            .collect();
6711        if selected.is_empty() {
6712            return Ok(current);
6713        }
6714        let mut wal = self.shared_wal.lock();
6715        wal.group_sync()?;
6716        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6717        let existing: HashSet<(u64, u64)> =
6718            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
6719                .into_iter()
6720                .filter_map(|record| match record.op {
6721                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
6722                    _ => None,
6723                })
6724                .collect();
6725        selected.retain(|txn_id| {
6726            commits
6727                .get(txn_id)
6728                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
6729        });
6730        for record in records {
6731            if !selected.contains(&record.txn_id) {
6732                continue;
6733            }
6734            match &record.op {
6735                Op::TxnCommit { epoch, added_runs } => {
6736                    let timestamp = commit_timestamps
6737                        .get(&record.txn_id)
6738                        .copied()
6739                        .unwrap_or_else(current_unix_nanos);
6740                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
6741                }
6742                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
6743                op => {
6744                    wal.append(record.txn_id, 0, op.clone())?;
6745                }
6746            }
6747        }
6748        if !selected.is_empty() {
6749            wal.group_sync()?;
6750        }
6751        drop(wal);
6752
6753        // Auth mode is selected before `finish_open` replays the WAL. Make the
6754        // catalog transition durable and publish security state to this live
6755        // handle before reporting success.
6756        let mut recovered_catalog = self.catalog.read().clone();
6757        if let Err(error) = recover_ddl_from_wal(
6758            &self.root,
6759            Some(&self.durable_root),
6760            &mut recovered_catalog,
6761            self.meta_dek.as_ref(),
6762            wal_dek.as_ref(),
6763            true,
6764            None,
6765        ) {
6766            return Err(MongrelError::DurableCommit {
6767                epoch: target_epoch,
6768                message: format!(
6769                    "replication WAL is durable but catalog checkpoint failed: {error}"
6770                ),
6771            });
6772        }
6773        let _security = self.security_coordinator.gate.write();
6774        let old_security_version = self.catalog.read().security_version;
6775        let security_changed = old_security_version != recovered_catalog.security_version
6776            || self.catalog.read().require_auth != recovered_catalog.require_auth;
6777        let require_auth = recovered_catalog.require_auth;
6778        let principal = if security_changed {
6779            None
6780        } else {
6781            self.principal.read().as_ref().and_then(|principal| {
6782                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
6783            })
6784        };
6785        if require_auth {
6786            self.auth_state.set_require_auth(true);
6787        }
6788        self.auth_state.set_principal(principal.clone());
6789        *self.principal.write() = principal;
6790        let security_version = recovered_catalog.security_version;
6791        *self.catalog.write() = recovered_catalog;
6792        self.security_coordinator
6793            .version
6794            .store(security_version, Ordering::Release);
6795        if !require_auth {
6796            self.auth_state.set_require_auth(false);
6797        }
6798        if let Err(error) =
6799            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
6800        {
6801            return Err(MongrelError::DurableCommit {
6802                epoch: target_epoch,
6803                message: format!(
6804                    "replication WAL and catalog are durable but follower watermark failed: {error}"
6805                ),
6806            });
6807        }
6808        Ok(target_epoch)
6809    }
6810
6811    /// Resolve a table name → id (live tables only). pub(crate) so the
6812    /// transaction layer can stage by name.
6813    pub fn table_id(&self, name: &str) -> Result<u64> {
6814        let cat = self.catalog.read();
6815        cat.live(name)
6816            .map(|e| e.table_id)
6817            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
6818    }
6819
6820    /// Return the stable table id and current schema generation from one
6821    /// catalog snapshot. Callers can bind retries to this identity so a table
6822    /// dropped and recreated under the same name is never mistaken for the
6823    /// original resource.
6824    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
6825        let catalog = self.catalog.read();
6826        catalog
6827            .live(name)
6828            .map(|entry| (entry.table_id, entry.schema.schema_id))
6829            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
6830    }
6831
6832    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
6833        self.catalog
6834            .read()
6835            .building(name)
6836            .map(|entry| entry.table_id)
6837            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
6838    }
6839
6840    pub fn procedures(&self) -> Vec<StoredProcedure> {
6841        self.catalog
6842            .read()
6843            .procedures
6844            .iter()
6845            .map(|p| p.procedure.clone())
6846            .collect()
6847    }
6848
6849    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
6850        self.catalog
6851            .read()
6852            .procedures
6853            .iter()
6854            .find(|p| p.procedure.name == name)
6855            .map(|p| p.procedure.clone())
6856    }
6857
6858    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
6859        self.create_procedure_inner(procedure, None)
6860    }
6861
6862    pub fn create_procedure_controlled<F>(
6863        &self,
6864        procedure: StoredProcedure,
6865        mut before_publish: F,
6866    ) -> Result<StoredProcedure>
6867    where
6868        F: FnMut() -> Result<()>,
6869    {
6870        self.create_procedure_inner(procedure, Some(&mut before_publish))
6871    }
6872
6873    fn create_procedure_inner(
6874        &self,
6875        mut procedure: StoredProcedure,
6876        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6877    ) -> Result<StoredProcedure> {
6878        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
6879            procedure: procedure.clone(),
6880        };
6881        self.require(&crate::catalog_cmds::required_permission(&command))?;
6882        let _g = self.ddl_lock.lock();
6883        let _security_write = self.security_write()?;
6884        self.require(&crate::catalog_cmds::required_permission(&command))?;
6885        procedure.validate()?;
6886        self.validate_procedure_references(&procedure)?;
6887        // S1F-001: validation runs pure against the current catalog first so
6888        // failures (duplicate name, unknown table) surface before an epoch is
6889        // consumed, exactly like the legacy pre-bump checks.
6890        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
6891        let commit_lock = Arc::clone(&self.commit_lock);
6892        let _c = commit_lock.lock();
6893        let epoch = self.epoch.bump_assigned();
6894        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6895        procedure.created_epoch = epoch.0;
6896        procedure.updated_epoch = epoch.0;
6897        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
6898            procedure: procedure.clone(),
6899        };
6900        let mut next_catalog = self.catalog.read().clone();
6901        self.apply_catalog_command_to(&mut next_catalog, command)?;
6902        next_catalog.db_epoch = epoch.0;
6903        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6904        Ok(procedure)
6905    }
6906
6907    pub fn create_or_replace_procedure(
6908        &self,
6909        procedure: StoredProcedure,
6910    ) -> Result<StoredProcedure> {
6911        self.create_or_replace_procedure_inner(procedure, None)
6912    }
6913
6914    pub fn create_or_replace_procedure_controlled<F>(
6915        &self,
6916        procedure: StoredProcedure,
6917        mut before_publish: F,
6918    ) -> Result<StoredProcedure>
6919    where
6920        F: FnMut() -> Result<()>,
6921    {
6922        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
6923    }
6924
6925    fn create_or_replace_procedure_inner(
6926        &self,
6927        procedure: StoredProcedure,
6928        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6929    ) -> Result<StoredProcedure> {
6930        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
6931            procedure: procedure.clone(),
6932        };
6933        self.require(&crate::catalog_cmds::required_permission(&command))?;
6934        let _g = self.ddl_lock.lock();
6935        let _security_write = self.security_write()?;
6936        self.require(&crate::catalog_cmds::required_permission(&command))?;
6937        procedure.validate()?;
6938        self.validate_procedure_references(&procedure)?;
6939        // S1F-001: validation runs pure against the current catalog first so
6940        // structural failures surface before an epoch is consumed, exactly
6941        // like the legacy pre-bump checks.
6942        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
6943        let commit_lock = Arc::clone(&self.commit_lock);
6944        let _c = commit_lock.lock();
6945        let epoch = self.epoch.bump_assigned();
6946        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6947        let mut next_catalog = self.catalog.read().clone();
6948        // Resolve the replacement image against the candidate catalog: the
6949        // engine stamps version/epochs from the commit epoch (preserving the
6950        // original created_epoch on replacement), then the command record
6951        // carries that resolved image.
6952        let replaced = match next_catalog
6953            .procedures
6954            .iter()
6955            .position(|p| p.procedure.name == procedure.name)
6956        {
6957            Some(idx) => next_catalog.procedures[idx]
6958                .procedure
6959                .replaced(procedure.clone(), epoch.0)?,
6960            None => {
6961                let mut next = procedure;
6962                next.created_epoch = epoch.0;
6963                next.updated_epoch = epoch.0;
6964                next
6965            }
6966        };
6967        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
6968            procedure: replaced.clone(),
6969        };
6970        self.apply_catalog_command_to(&mut next_catalog, command)?;
6971        next_catalog.db_epoch = epoch.0;
6972        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6973        Ok(replaced)
6974    }
6975
6976    pub fn drop_procedure(&self, name: &str) -> Result<()> {
6977        self.drop_procedure_with_epoch(name).map(|_| ())
6978    }
6979
6980    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
6981        self.drop_procedure_with_epoch_inner(name, None)
6982    }
6983
6984    pub fn drop_procedure_with_epoch_controlled<F>(
6985        &self,
6986        name: &str,
6987        mut before_publish: F,
6988    ) -> Result<Epoch>
6989    where
6990        F: FnMut() -> Result<()>,
6991    {
6992        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
6993    }
6994
6995    fn drop_procedure_with_epoch_inner(
6996        &self,
6997        name: &str,
6998        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6999    ) -> Result<Epoch> {
7000        let command = crate::catalog_cmds::CatalogCommand::DropProcedure {
7001            name: name.to_string(),
7002        };
7003        self.require(&crate::catalog_cmds::required_permission(&command))?;
7004        let _g = self.ddl_lock.lock();
7005        let _security_write = self.security_write()?;
7006        self.require(&crate::catalog_cmds::required_permission(&command))?;
7007        let commit_lock = Arc::clone(&self.commit_lock);
7008        let _c = commit_lock.lock();
7009        let epoch = self.epoch.bump_assigned();
7010        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7011        let mut next_catalog = self.catalog.read().clone();
7012        self.apply_catalog_command_to(&mut next_catalog, command)?;
7013        next_catalog.db_epoch = epoch.0;
7014        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7015        Ok(epoch)
7016    }
7017
7018    // ── User / role / credentials management ─────────────────────────────
7019
7020    /// List all catalog users (password hashes included — callers should not
7021    /// serialize them externally).
7022    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
7023        self.catalog.read().users.clone()
7024    }
7025
7026    /// Resolve only the stable, non-secret identity fields needed to scope
7027    /// request receipts. Password hashes never leave the catalog lock.
7028    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
7029        self.catalog
7030            .read()
7031            .users
7032            .iter()
7033            .find(|user| user.username == username)
7034            .map(|user| (user.id, user.created_epoch))
7035    }
7036
7037    /// Current catalog authorization generation. Retry bindings can include
7038    /// this value to fail closed after roles, grants, or row policies change.
7039    pub fn security_version(&self) -> u64 {
7040        self.catalog.read().security_version
7041    }
7042
7043    /// List all catalog roles.
7044    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
7045        self.catalog.read().roles.clone()
7046    }
7047
7048    /// Create a new user with an Argon2id-hashed password.
7049    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
7050        self.require(&crate::auth::Permission::Admin)?;
7051        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
7052        self.create_user_with_password_hash(username, hash)
7053    }
7054
7055    /// Create a user from a password hash prepared before a commit fence.
7056    pub fn create_user_with_password_hash(
7057        &self,
7058        username: &str,
7059        hash: String,
7060    ) -> Result<crate::auth::UserEntry> {
7061        self.create_user_with_password_hash_inner(username, hash, None)
7062    }
7063
7064    pub fn create_user_with_password_hash_controlled<F>(
7065        &self,
7066        username: &str,
7067        hash: String,
7068        mut before_publish: F,
7069    ) -> Result<crate::auth::UserEntry>
7070    where
7071        F: FnMut() -> Result<()>,
7072    {
7073        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
7074    }
7075
7076    fn create_user_with_password_hash_inner(
7077        &self,
7078        username: &str,
7079        hash: String,
7080        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7081    ) -> Result<crate::auth::UserEntry> {
7082        // S1F-001: the mutation is a versioned catalog command; its required
7083        // permission is checked against the caller principal first (identical
7084        // to the legacy hardcoded Admin gate).
7085        let command = crate::catalog_cmds::CatalogCommand::CreateUser {
7086            username: username.to_string(),
7087            password_hash: hash.clone(),
7088            is_admin: false,
7089            created_epoch: 0, // stamped after the commit epoch is assigned
7090        };
7091        self.require(&crate::catalog_cmds::required_permission(&command))?;
7092        let _ddl = self.ddl_lock.lock();
7093        let _security_write = self.security_write()?;
7094        self.require(&crate::catalog_cmds::required_permission(&command))?;
7095        let _commit = self.commit_lock.lock();
7096        let epoch = self.epoch.bump_assigned();
7097        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7098        let command = crate::catalog_cmds::CatalogCommand::CreateUser {
7099            username: username.to_string(),
7100            password_hash: hash,
7101            is_admin: false,
7102            created_epoch: epoch.0,
7103        };
7104        let mut next_catalog = self.catalog.read().clone();
7105        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
7106        let crate::catalog_cmds::CatalogDelta::UserUpserted(entry) = delta else {
7107            return Err(MongrelError::Other(
7108                "CreateUser resolved to an unexpected catalog delta".into(),
7109            ));
7110        };
7111        next_catalog.db_epoch = epoch.0;
7112        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7113        Ok(entry)
7114    }
7115
7116    /// Drop a user by username.
7117    pub fn drop_user(&self, username: &str) -> Result<()> {
7118        self.drop_user_with_epoch(username).map(|_| ())
7119    }
7120
7121    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
7122        self.drop_user_with_epoch_inner(username, None)
7123    }
7124
7125    pub fn drop_user_with_epoch_controlled<F>(
7126        &self,
7127        username: &str,
7128        mut before_publish: F,
7129    ) -> Result<Epoch>
7130    where
7131        F: FnMut() -> Result<()>,
7132    {
7133        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
7134    }
7135
7136    fn drop_user_with_epoch_inner(
7137        &self,
7138        username: &str,
7139        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7140    ) -> Result<Epoch> {
7141        let command = crate::catalog_cmds::CatalogCommand::DropUser {
7142            username: username.to_string(),
7143        };
7144        self.require(&crate::catalog_cmds::required_permission(&command))?;
7145        let _ddl = self.ddl_lock.lock();
7146        let _security_write = self.security_write()?;
7147        self.require(&crate::catalog_cmds::required_permission(&command))?;
7148        let _commit = self.commit_lock.lock();
7149        let epoch = self.epoch.bump_assigned();
7150        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7151        let mut next_catalog = self.catalog.read().clone();
7152        self.apply_catalog_command_to(&mut next_catalog, command)?;
7153        next_catalog.db_epoch = epoch.0;
7154        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7155        Ok(epoch)
7156    }
7157
7158    /// Change a user's password.
7159    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
7160        self.alter_user_password_with_epoch(username, new_password)
7161            .map(|_| ())
7162    }
7163
7164    pub fn alter_user_password_with_epoch(
7165        &self,
7166        username: &str,
7167        new_password: &str,
7168    ) -> Result<Epoch> {
7169        self.require(&crate::auth::Permission::Admin)?;
7170        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
7171        self.alter_user_password_hash_with_epoch(username, hash)
7172    }
7173
7174    pub fn alter_user_password_hash_with_epoch(
7175        &self,
7176        username: &str,
7177        hash: String,
7178    ) -> Result<Epoch> {
7179        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
7180    }
7181
7182    pub fn alter_user_password_hash_with_epoch_controlled<F>(
7183        &self,
7184        username: &str,
7185        hash: String,
7186        mut before_publish: F,
7187    ) -> Result<Epoch>
7188    where
7189        F: FnMut() -> Result<()>,
7190    {
7191        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
7192    }
7193
7194    fn alter_user_password_hash_with_epoch_inner(
7195        &self,
7196        username: &str,
7197        hash: String,
7198        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7199    ) -> Result<Epoch> {
7200        let command = crate::catalog_cmds::CatalogCommand::AlterUserPassword {
7201            username: username.to_string(),
7202            password_hash: hash,
7203        };
7204        self.require(&crate::catalog_cmds::required_permission(&command))?;
7205        let _ddl = self.ddl_lock.lock();
7206        let _security_write = self.security_write()?;
7207        self.require(&crate::catalog_cmds::required_permission(&command))?;
7208        let _commit = self.commit_lock.lock();
7209        let epoch = self.epoch.bump_assigned();
7210        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7211        let mut next_catalog = self.catalog.read().clone();
7212        self.apply_catalog_command_to(&mut next_catalog, command)?;
7213        next_catalog.db_epoch = epoch.0;
7214        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7215        Ok(epoch)
7216    }
7217
7218    /// Verify credentials. Returns `Some(entry)` on success, `None` on
7219    /// mismatch, `Err` on engine error.
7220    pub fn verify_user(
7221        &self,
7222        username: &str,
7223        password: &str,
7224    ) -> Result<Option<crate::auth::UserEntry>> {
7225        let cat = self.catalog.read();
7226        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
7227            return Ok(None);
7228        };
7229        if user.password_hash.is_empty() {
7230            return Ok(None);
7231        }
7232        let ok = crate::auth::verify_password(password, &user.password_hash)
7233            .map_err(MongrelError::Other)?;
7234        if ok {
7235            Ok(Some(user.clone()))
7236        } else {
7237            Ok(None)
7238        }
7239    }
7240
7241    /// Authenticate and resolve one immutable principal from the same catalog
7242    /// snapshot. Username reuse cannot bridge the password check and principal
7243    /// resolution.
7244    pub fn authenticate_principal(
7245        &self,
7246        username: &str,
7247        password: &str,
7248    ) -> Result<Option<crate::auth::Principal>> {
7249        self.authenticate_principal_inner(username, password, || {})
7250    }
7251
7252    fn authenticate_principal_inner<F>(
7253        &self,
7254        username: &str,
7255        password: &str,
7256        after_verify: F,
7257    ) -> Result<Option<crate::auth::Principal>>
7258    where
7259        F: FnOnce(),
7260    {
7261        let catalog = self.catalog.read();
7262        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
7263            return Ok(None);
7264        };
7265        if user.password_hash.is_empty()
7266            || !crate::auth::verify_password(password, &user.password_hash)
7267                .map_err(MongrelError::Other)?
7268        {
7269            return Ok(None);
7270        }
7271        after_verify();
7272        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
7273    }
7274
7275    /// Grant admin privileges to a user (bypasses all permission checks).
7276    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
7277        self.set_user_admin_with_epoch(username, is_admin)
7278            .map(|_| ())
7279    }
7280
7281    pub fn set_user_admin_with_epoch(
7282        &self,
7283        username: &str,
7284        is_admin: bool,
7285    ) -> Result<Option<Epoch>> {
7286        self.set_user_admin_with_epoch_inner(username, is_admin, None)
7287    }
7288
7289    pub fn set_user_admin_with_epoch_controlled<F>(
7290        &self,
7291        username: &str,
7292        is_admin: bool,
7293        mut before_publish: F,
7294    ) -> Result<Option<Epoch>>
7295    where
7296        F: FnMut() -> Result<()>,
7297    {
7298        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
7299    }
7300
7301    fn set_user_admin_with_epoch_inner(
7302        &self,
7303        username: &str,
7304        is_admin: bool,
7305        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7306    ) -> Result<Option<Epoch>> {
7307        let command = crate::catalog_cmds::CatalogCommand::SetUserAdmin {
7308            username: username.to_string(),
7309            is_admin,
7310        };
7311        self.require(&crate::catalog_cmds::required_permission(&command))?;
7312        let _ddl = self.ddl_lock.lock();
7313        let _security_write = self.security_write()?;
7314        self.require(&crate::catalog_cmds::required_permission(&command))?;
7315        let _commit = self.commit_lock.lock();
7316        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7317        // short-circuit), so validation runs pure first and only real changes
7318        // are applied and recorded.
7319        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7320        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7321            return Ok(None);
7322        }
7323        let epoch = self.epoch.bump_assigned();
7324        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7325        let mut next_catalog = self.catalog.read().clone();
7326        self.apply_catalog_command_to(&mut next_catalog, command)?;
7327        next_catalog.db_epoch = epoch.0;
7328        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7329        Ok(Some(epoch))
7330    }
7331
7332    /// Create a new role.
7333    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
7334        self.create_role_inner(name, None)
7335    }
7336
7337    pub fn create_role_controlled<F>(
7338        &self,
7339        name: &str,
7340        mut before_publish: F,
7341    ) -> Result<crate::auth::RoleEntry>
7342    where
7343        F: FnMut() -> Result<()>,
7344    {
7345        self.create_role_inner(name, Some(&mut before_publish))
7346    }
7347
7348    fn create_role_inner(
7349        &self,
7350        name: &str,
7351        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7352    ) -> Result<crate::auth::RoleEntry> {
7353        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
7354            name: name.to_string(),
7355            created_epoch: 0, // stamped after the commit epoch is assigned
7356        };
7357        self.require(&crate::catalog_cmds::required_permission(&command))?;
7358        let _ddl = self.ddl_lock.lock();
7359        let _security_write = self.security_write()?;
7360        self.require(&crate::catalog_cmds::required_permission(&command))?;
7361        let _commit = self.commit_lock.lock();
7362        let epoch = self.epoch.bump_assigned();
7363        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7364        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
7365            name: name.to_string(),
7366            created_epoch: epoch.0,
7367        };
7368        let mut next_catalog = self.catalog.read().clone();
7369        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
7370        let crate::catalog_cmds::CatalogDelta::RoleUpserted(entry) = delta else {
7371            return Err(MongrelError::Other(
7372                "CreateRole resolved to an unexpected catalog delta".into(),
7373            ));
7374        };
7375        next_catalog.db_epoch = epoch.0;
7376        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7377        Ok(entry)
7378    }
7379
7380    /// Drop a role by name.
7381    pub fn drop_role(&self, name: &str) -> Result<()> {
7382        self.drop_role_with_epoch(name).map(|_| ())
7383    }
7384
7385    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
7386        self.drop_role_with_epoch_inner(name, None)
7387    }
7388
7389    pub fn drop_role_with_epoch_controlled<F>(
7390        &self,
7391        name: &str,
7392        mut before_publish: F,
7393    ) -> Result<Epoch>
7394    where
7395        F: FnMut() -> Result<()>,
7396    {
7397        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
7398    }
7399
7400    fn drop_role_with_epoch_inner(
7401        &self,
7402        name: &str,
7403        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7404    ) -> Result<Epoch> {
7405        let command = crate::catalog_cmds::CatalogCommand::DropRole {
7406            name: name.to_string(),
7407        };
7408        self.require(&crate::catalog_cmds::required_permission(&command))?;
7409        let _ddl = self.ddl_lock.lock();
7410        let _security_write = self.security_write()?;
7411        self.require(&crate::catalog_cmds::required_permission(&command))?;
7412        let _commit = self.commit_lock.lock();
7413        let epoch = self.epoch.bump_assigned();
7414        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7415        let mut next_catalog = self.catalog.read().clone();
7416        self.apply_catalog_command_to(&mut next_catalog, command)?;
7417        next_catalog.db_epoch = epoch.0;
7418        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7419        Ok(epoch)
7420    }
7421
7422    /// Grant a role to a user.
7423    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
7424        self.grant_role_with_epoch(username, role_name).map(|_| ())
7425    }
7426
7427    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
7428        self.grant_role_with_epoch_inner(username, role_name, None)
7429    }
7430
7431    pub fn grant_role_with_epoch_controlled<F>(
7432        &self,
7433        username: &str,
7434        role_name: &str,
7435        mut before_publish: F,
7436    ) -> Result<Option<Epoch>>
7437    where
7438        F: FnMut() -> Result<()>,
7439    {
7440        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
7441    }
7442
7443    fn grant_role_with_epoch_inner(
7444        &self,
7445        username: &str,
7446        role_name: &str,
7447        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7448    ) -> Result<Option<Epoch>> {
7449        let command = crate::catalog_cmds::CatalogCommand::GrantRole {
7450            username: username.to_string(),
7451            role: role_name.to_string(),
7452        };
7453        self.require(&crate::catalog_cmds::required_permission(&command))?;
7454        let _ddl = self.ddl_lock.lock();
7455        let _security_write = self.security_write()?;
7456        self.require(&crate::catalog_cmds::required_permission(&command))?;
7457        let _commit = self.commit_lock.lock();
7458        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7459        // short-circuit), so validation runs pure first and only real changes
7460        // are applied and recorded.
7461        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7462        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7463            return Ok(None);
7464        }
7465        let epoch = self.epoch.bump_assigned();
7466        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7467        let mut next_catalog = self.catalog.read().clone();
7468        self.apply_catalog_command_to(&mut next_catalog, command)?;
7469        next_catalog.db_epoch = epoch.0;
7470        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7471        Ok(Some(epoch))
7472    }
7473
7474    /// Revoke a role from a user.
7475    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
7476        self.revoke_role_with_epoch(username, role_name).map(|_| ())
7477    }
7478
7479    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
7480        self.revoke_role_with_epoch_inner(username, role_name, None)
7481    }
7482
7483    pub fn revoke_role_with_epoch_controlled<F>(
7484        &self,
7485        username: &str,
7486        role_name: &str,
7487        mut before_publish: F,
7488    ) -> Result<Option<Epoch>>
7489    where
7490        F: FnMut() -> Result<()>,
7491    {
7492        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
7493    }
7494
7495    fn revoke_role_with_epoch_inner(
7496        &self,
7497        username: &str,
7498        role_name: &str,
7499        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7500    ) -> Result<Option<Epoch>> {
7501        let command = crate::catalog_cmds::CatalogCommand::RevokeRole {
7502            username: username.to_string(),
7503            role: role_name.to_string(),
7504        };
7505        self.require(&crate::catalog_cmds::required_permission(&command))?;
7506        let _ddl = self.ddl_lock.lock();
7507        let _security_write = self.security_write()?;
7508        self.require(&crate::catalog_cmds::required_permission(&command))?;
7509        let _commit = self.commit_lock.lock();
7510        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7511        // short-circuit), so validation runs pure first and only real changes
7512        // are applied and recorded.
7513        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7514        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7515            return Ok(None);
7516        }
7517        let epoch = self.epoch.bump_assigned();
7518        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7519        let mut next_catalog = self.catalog.read().clone();
7520        self.apply_catalog_command_to(&mut next_catalog, command)?;
7521        next_catalog.db_epoch = epoch.0;
7522        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7523        Ok(Some(epoch))
7524    }
7525
7526    /// Grant a permission to a role.
7527    pub fn grant_permission(
7528        &self,
7529        role_name: &str,
7530        permission: crate::auth::Permission,
7531    ) -> Result<()> {
7532        self.grant_permission_with_epoch(role_name, permission)
7533            .map(|_| ())
7534    }
7535
7536    pub fn grant_permission_with_epoch(
7537        &self,
7538        role_name: &str,
7539        permission: crate::auth::Permission,
7540    ) -> Result<Option<Epoch>> {
7541        self.grant_permission_with_epoch_inner(role_name, permission, None)
7542    }
7543
7544    pub fn grant_permission_with_epoch_controlled<F>(
7545        &self,
7546        role_name: &str,
7547        permission: crate::auth::Permission,
7548        mut before_publish: F,
7549    ) -> Result<Option<Epoch>>
7550    where
7551        F: FnMut() -> Result<()>,
7552    {
7553        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
7554    }
7555
7556    fn grant_permission_with_epoch_inner(
7557        &self,
7558        role_name: &str,
7559        permission: crate::auth::Permission,
7560        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7561    ) -> Result<Option<Epoch>> {
7562        let command = crate::catalog_cmds::CatalogCommand::GrantPermission {
7563            role: role_name.to_string(),
7564            permission,
7565        };
7566        self.require(&crate::catalog_cmds::required_permission(&command))?;
7567        let _ddl = self.ddl_lock.lock();
7568        let _security_write = self.security_write()?;
7569        self.require(&crate::catalog_cmds::required_permission(&command))?;
7570        let _commit = self.commit_lock.lock();
7571        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7572        // short-circuit), so validation runs pure first and only real changes
7573        // are applied and recorded.
7574        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7575        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7576            return Ok(None);
7577        }
7578        let epoch = self.epoch.bump_assigned();
7579        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7580        let mut next_catalog = self.catalog.read().clone();
7581        self.apply_catalog_command_to(&mut next_catalog, command)?;
7582        next_catalog.db_epoch = epoch.0;
7583        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7584        Ok(Some(epoch))
7585    }
7586
7587    /// Revoke a permission from a role.
7588    pub fn revoke_permission(
7589        &self,
7590        role_name: &str,
7591        permission: crate::auth::Permission,
7592    ) -> Result<()> {
7593        self.revoke_permission_with_epoch(role_name, permission)
7594            .map(|_| ())
7595    }
7596
7597    pub fn revoke_permission_with_epoch(
7598        &self,
7599        role_name: &str,
7600        permission: crate::auth::Permission,
7601    ) -> Result<Option<Epoch>> {
7602        self.revoke_permission_with_epoch_inner(role_name, permission, None)
7603    }
7604
7605    pub fn revoke_permission_with_epoch_controlled<F>(
7606        &self,
7607        role_name: &str,
7608        permission: crate::auth::Permission,
7609        mut before_publish: F,
7610    ) -> Result<Option<Epoch>>
7611    where
7612        F: FnMut() -> Result<()>,
7613    {
7614        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
7615    }
7616
7617    fn revoke_permission_with_epoch_inner(
7618        &self,
7619        role_name: &str,
7620        permission: crate::auth::Permission,
7621        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7622    ) -> Result<Option<Epoch>> {
7623        let command = crate::catalog_cmds::CatalogCommand::RevokePermission {
7624            role: role_name.to_string(),
7625            permission,
7626        };
7627        self.require(&crate::catalog_cmds::required_permission(&command))?;
7628        let _ddl = self.ddl_lock.lock();
7629        let _security_write = self.security_write()?;
7630        self.require(&crate::catalog_cmds::required_permission(&command))?;
7631        let _commit = self.commit_lock.lock();
7632        // Idempotent no-ops publish nothing and consume no epoch (the legacy
7633        // short-circuit), so validation runs pure first and only real changes
7634        // are applied and recorded.
7635        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7636        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
7637            return Ok(None);
7638        }
7639        let epoch = self.epoch.bump_assigned();
7640        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7641        let mut next_catalog = self.catalog.read().clone();
7642        self.apply_catalog_command_to(&mut next_catalog, command)?;
7643        next_catalog.db_epoch = epoch.0;
7644        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7645        Ok(Some(epoch))
7646    }
7647
7648    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
7649    /// permissions from their roles. Returns `None` if the user doesn't exist.
7650    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
7651        let cat = self.catalog.read();
7652        Self::resolve_principal_from_catalog(&cat, username)
7653    }
7654
7655    /// Re-resolve only when the immutable user identity still exists. This is
7656    /// the server/session validation path; username reuse never matches.
7657    pub fn resolve_current_principal(
7658        &self,
7659        principal: &crate::auth::Principal,
7660    ) -> Option<crate::auth::Principal> {
7661        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
7662    }
7663
7664    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
7665    /// without needing a constructed `Database`. Used by the credentialed open
7666    /// path (which must verify credentials before the `Database` exists) and
7667    /// by [`resolve_principal`](Self::resolve_principal).
7668    fn resolve_principal_from_catalog(
7669        cat: &Catalog,
7670        username: &str,
7671    ) -> Option<crate::auth::Principal> {
7672        let user = cat.users.iter().find(|u| u.username == username)?;
7673        Self::resolve_user_principal_from_catalog(cat, user)
7674    }
7675
7676    fn resolve_bound_principal_from_catalog(
7677        cat: &Catalog,
7678        principal: &crate::auth::Principal,
7679    ) -> Option<crate::auth::Principal> {
7680        let user = cat.users.iter().find(|user| {
7681            user.id == principal.user_id
7682                && user.created_epoch == principal.created_epoch
7683                && user.username == principal.username
7684        })?;
7685        Self::resolve_user_principal_from_catalog(cat, user)
7686    }
7687
7688    fn resolve_user_principal_from_catalog(
7689        cat: &Catalog,
7690        user: &crate::auth::UserEntry,
7691    ) -> Option<crate::auth::Principal> {
7692        let mut permissions = Vec::new();
7693        for role_name in &user.roles {
7694            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
7695                permissions.extend(role.permissions.iter().cloned());
7696            }
7697        }
7698        Some(crate::auth::Principal {
7699            user_id: user.id,
7700            created_epoch: user.created_epoch,
7701            username: user.username.clone(),
7702            is_admin: user.is_admin,
7703            roles: user.roles.clone(),
7704            permissions,
7705        })
7706    }
7707
7708    /// Check whether a user has a specific permission (via their roles).
7709    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
7710        match self.resolve_principal(username) {
7711            Some(p) => p.has_permission(permission),
7712            None => false,
7713        }
7714    }
7715
7716    /// Returns `true` if this database's catalog has `require_auth = true`.
7717    /// When true, every operation consults the cached [`Principal`] via
7718    /// [`require`](Self::require).
7719    pub fn require_auth_enabled(&self) -> bool {
7720        self.catalog.read().require_auth
7721    }
7722
7723    /// A snapshot of the cached principal for this handle, if any. `None` for
7724    /// databases opened without credentials (the default). Returns a clone
7725    /// because the principal lives behind an `RwLock`.
7726    pub fn principal(&self) -> Option<crate::auth::Principal> {
7727        self.principal.read().clone()
7728    }
7729
7730    /// Build a `TableAuthChecker` from the current auth state. Used when
7731    /// mounting a new table (`create_table`) so the table inherits the
7732    /// database's enforcement configuration. The checker reads the live
7733    /// `require_auth` flag and cached principal, so changes via `enable_auth`
7734    /// / `refresh_principal` propagate to already-mounted tables.
7735    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
7736        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
7737            self.auth_state.clone(),
7738        )))
7739    }
7740
7741    /// Re-resolve the cached principal from the shared current catalog.
7742    /// Long-lived
7743    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
7744    /// possibly made by a different handle to the same database — to pick up
7745    /// the new effective permissions without re-verifying the password.
7746    ///
7747    /// The process-wide security version reloads from disk only when another
7748    /// handle published a newer catalog. The username is taken from
7749    /// the existing cached principal; if the user has since been dropped,
7750    /// returns [`MongrelError::InvalidCredentials`].
7751    ///
7752    /// No-op (returns `Ok(())`) on a credentialless database, or on a
7753    /// credentialed database whose cached principal is `None`.
7754    pub fn refresh_principal(&self) -> Result<()> {
7755        let previous = match self.principal.read().clone() {
7756            Some(principal) => principal,
7757            None => return Ok(()),
7758        };
7759        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
7760        self.refresh_security_catalog_if_stale(observed_version)?;
7761        let cat = self.catalog.read();
7762        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
7763            Some(p) => {
7764                *self.principal.write() = Some(p.clone());
7765                // Update the shared auth state so mounted Tables see the new
7766                // permissions immediately (Tables read from AuthState, not from
7767                // self.principal).
7768                self.auth_state.set_principal(Some(p));
7769                Ok(())
7770            }
7771            None => Err(MongrelError::InvalidCredentials {
7772                username: previous.username,
7773            }),
7774        }
7775    }
7776
7777    /// Number of security-catalog disk reloads performed by this open handle.
7778    /// Initial open reads are excluded.
7779    pub fn security_catalog_disk_read_count(&self) -> u64 {
7780        self.security_catalog_disk_reads.load(Ordering::Relaxed)
7781    }
7782
7783    /// Convert a credentialless database to a credentialed one: create the
7784    /// first admin user, set `require_auth = true`, and cache the admin
7785    /// principal on this handle so subsequent operations on the same handle
7786    /// continue to work. After this call, the database can only be reopened
7787    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
7788    ///
7789    /// Refuses if the database already has `require_auth = true`. This is
7790    /// the conversion path for existing databases; for fresh databases,
7791    /// `create_with_credentials` sets everything up atomically.
7792    ///
7793    /// See `docs/15-credential-enforcement.md`.
7794    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
7795        if self.shared {
7796            // Fail closed (spec §4.6): one shared handle must not flip the
7797            // core into an enforcement mode the other handles cannot observe.
7798            // Stage 1A shared cores stay credentialless; auth-mode transitions
7799            // require an exclusive `Database` owner. Per-handle principals
7800            // land with Stage 1D sessions.
7801            return Err(MongrelError::Conflict(
7802                "enable_auth requires an exclusive Database owner; shared cores reject \
7803                 auth-mode transitions"
7804                    .into(),
7805            ));
7806        }
7807        let password_hash =
7808            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
7809        let _ddl = self.ddl_lock.lock();
7810        let _security_write = self.security_write()?;
7811        let _commit = self.commit_lock.lock();
7812        let epoch = self.epoch.bump_assigned();
7813        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7814        let mut next_catalog = self.catalog.read().clone();
7815        if next_catalog.require_auth {
7816            return Err(MongrelError::InvalidArgument(
7817                "database already has require_auth enabled".into(),
7818            ));
7819        }
7820        if next_catalog
7821            .users
7822            .iter()
7823            .any(|u| u.username == admin_username)
7824        {
7825            return Err(MongrelError::InvalidArgument(format!(
7826                "user {admin_username:?} already exists"
7827            )));
7828        }
7829        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
7830        let id = next_catalog.next_user_id;
7831        next_catalog.next_user_id = id
7832            .checked_add(1)
7833            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
7834        next_catalog.users.push(crate::auth::UserEntry {
7835            id,
7836            username: admin_username.to_string(),
7837            password_hash,
7838            roles: Vec::new(),
7839            is_admin: true,
7840            created_epoch: epoch.0,
7841        });
7842        next_catalog.require_auth = true;
7843        advance_security_version(&mut next_catalog)?;
7844        next_catalog.db_epoch = epoch.0;
7845        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
7846        // Cache the admin principal on this handle + update the shared auth
7847        // state whenever rename published, even if directory fsync was
7848        // inconclusive.
7849        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
7850            let principal = crate::auth::Principal {
7851                user_id: id,
7852                created_epoch: epoch.0,
7853                username: admin_username.to_string(),
7854                is_admin: true,
7855                roles: Vec::new(),
7856                permissions: Vec::new(),
7857            };
7858            *self.principal.write() = Some(principal.clone());
7859            self.auth_state.set_principal(Some(principal));
7860        }
7861        publish
7862    }
7863
7864    /// Disable `require_auth` on this database, reverting it to credentialless
7865    /// mode. This is the **recovery** path — it requires the handle to already
7866    /// be open (and therefore already authenticated if `require_auth` was on).
7867    ///
7868    /// After this call, the database can be reopened with plain
7869    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
7870    /// credentials. All existing users and roles are preserved in the catalog
7871    /// (so `require_auth` can be re-enabled without recreating them), but they
7872    /// are no longer consulted for enforcement.
7873    ///
7874    /// For true **offline** recovery (when credentials are lost and no
7875    /// authenticated handle is available), the caller opens the database
7876    /// directly via the catalog file (filesystem access required) and calls
7877    /// this method — see the CLI's `auth disable-offline` command.
7878    ///
7879    /// See `docs/15-credential-enforcement.md` §4.7.
7880    pub fn disable_auth(&self) -> Result<()> {
7881        let _ddl = self.ddl_lock.lock();
7882        let _security_write = self.security_write()?;
7883        let _commit = self.commit_lock.lock();
7884        let epoch = self.epoch.bump_assigned();
7885        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7886        let mut next_catalog = self.catalog.read().clone();
7887        if !next_catalog.require_auth {
7888            return Err(MongrelError::InvalidArgument(
7889                "database does not have require_auth enabled".into(),
7890            ));
7891        }
7892        next_catalog.require_auth = false;
7893        advance_security_version(&mut next_catalog)?;
7894        next_catalog.db_epoch = epoch.0;
7895        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
7896        // Clear the cached principal — enforcement is now off.
7897        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
7898            *self.principal.write() = None;
7899        }
7900        publish
7901    }
7902
7903    /// Enforcement check: if the catalog has `require_auth = true`, verify
7904    /// that the cached principal satisfies `perm`. Called by every
7905    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
7906    /// Table/Transaction/MongrelSession operations).
7907    ///
7908    /// On a credentialless database this is a no-op (`Ok(())`).
7909    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
7910        self.ensure_owner_process()?;
7911        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
7912            return Err(MongrelError::ReadOnlyReplica);
7913        }
7914        if self.principal.read().is_some() {
7915            self.refresh_principal().map_err(|error| match error {
7916                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
7917                error => error,
7918            })?;
7919        }
7920        if !self.catalog.read().require_auth {
7921            return Ok(());
7922        }
7923        let guard = self.principal.read();
7924        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
7925        if p.has_permission(perm) {
7926            Ok(())
7927        } else {
7928            Err(MongrelError::PermissionDenied {
7929                required: perm.clone(),
7930                principal: p.username.clone(),
7931            })
7932        }
7933    }
7934
7935    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
7936    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
7937    /// other callers that know the operation kind + table name but don't want
7938    /// to construct the full `Permission` enum value themselves.
7939    pub fn require_table(
7940        &self,
7941        table: &str,
7942        perm: crate::auth_state::RequiredPermission,
7943    ) -> Result<()> {
7944        self.require(&perm.into_permission(table))
7945    }
7946
7947    pub fn triggers(&self) -> Vec<StoredTrigger> {
7948        self.catalog
7949            .read()
7950            .triggers
7951            .iter()
7952            .map(|t| t.trigger.clone())
7953            .collect()
7954    }
7955
7956    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
7957        self.catalog
7958            .read()
7959            .triggers
7960            .iter()
7961            .find(|t| t.trigger.name == name)
7962            .map(|t| t.trigger.clone())
7963    }
7964
7965    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
7966        self.create_trigger_inner(trigger, None, None)
7967    }
7968
7969    pub fn create_trigger_controlled<F>(
7970        &self,
7971        trigger: StoredTrigger,
7972        mut before_publish: F,
7973    ) -> Result<StoredTrigger>
7974    where
7975        F: FnMut() -> Result<()>,
7976    {
7977        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
7978    }
7979
7980    pub fn create_trigger_as_controlled<F>(
7981        &self,
7982        trigger: StoredTrigger,
7983        principal: Option<&crate::auth::Principal>,
7984        mut before_publish: F,
7985    ) -> Result<StoredTrigger>
7986    where
7987        F: FnMut() -> Result<()>,
7988    {
7989        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
7990    }
7991
7992    fn create_trigger_inner(
7993        &self,
7994        mut trigger: StoredTrigger,
7995        principal: Option<&crate::auth::Principal>,
7996        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7997    ) -> Result<StoredTrigger> {
7998        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
7999            trigger: trigger.clone(),
8000        };
8001        self.require_for(
8002            principal,
8003            &crate::catalog_cmds::required_permission(&command),
8004        )?;
8005        let _g = self.ddl_lock.lock();
8006        let _security_write = self.security_write()?;
8007        self.require_for(
8008            principal,
8009            &crate::catalog_cmds::required_permission(&command),
8010        )?;
8011        trigger.validate()?;
8012        self.validate_trigger_references(&trigger)
8013            .map_err(trigger_validation_error)?;
8014        // S1F-001: validation runs pure against the current catalog first so
8015        // failures (duplicate name, unknown target) surface before an epoch is
8016        // consumed, exactly like the legacy pre-bump checks.
8017        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8018        let commit_lock = Arc::clone(&self.commit_lock);
8019        let _c = commit_lock.lock();
8020        let epoch = self.epoch.bump_assigned();
8021        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8022        trigger.created_epoch = epoch.0;
8023        trigger.updated_epoch = epoch.0;
8024        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
8025            trigger: trigger.clone(),
8026        };
8027        let mut next_catalog = self.catalog.read().clone();
8028        self.apply_catalog_command_to(&mut next_catalog, command)?;
8029        next_catalog.db_epoch = epoch.0;
8030        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8031        Ok(trigger)
8032    }
8033
8034    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
8035        self.create_or_replace_trigger_inner(trigger, None, None)
8036    }
8037
8038    pub fn create_or_replace_trigger_controlled<F>(
8039        &self,
8040        trigger: StoredTrigger,
8041        mut before_publish: F,
8042    ) -> Result<StoredTrigger>
8043    where
8044        F: FnMut() -> Result<()>,
8045    {
8046        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
8047    }
8048
8049    pub fn create_or_replace_trigger_as_controlled<F>(
8050        &self,
8051        trigger: StoredTrigger,
8052        principal: Option<&crate::auth::Principal>,
8053        mut before_publish: F,
8054    ) -> Result<StoredTrigger>
8055    where
8056        F: FnMut() -> Result<()>,
8057    {
8058        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
8059    }
8060
8061    fn create_or_replace_trigger_inner(
8062        &self,
8063        trigger: StoredTrigger,
8064        principal: Option<&crate::auth::Principal>,
8065        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8066    ) -> Result<StoredTrigger> {
8067        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
8068            trigger: trigger.clone(),
8069        };
8070        self.require_for(
8071            principal,
8072            &crate::catalog_cmds::required_permission(&command),
8073        )?;
8074        let _g = self.ddl_lock.lock();
8075        let _security_write = self.security_write()?;
8076        self.require_for(
8077            principal,
8078            &crate::catalog_cmds::required_permission(&command),
8079        )?;
8080        trigger.validate()?;
8081        self.validate_trigger_references(&trigger)
8082            .map_err(trigger_validation_error)?;
8083        // S1F-001: validation runs pure against the current catalog first so
8084        // structural failures surface before an epoch is consumed, exactly
8085        // like the legacy pre-bump checks.
8086        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8087        let commit_lock = Arc::clone(&self.commit_lock);
8088        let _c = commit_lock.lock();
8089        let epoch = self.epoch.bump_assigned();
8090        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8091        let mut next_catalog = self.catalog.read().clone();
8092        // Resolve the replacement image against the candidate catalog: the
8093        // engine stamps version/epochs from the commit epoch (preserving the
8094        // original created_epoch on replacement), then the command record
8095        // carries that resolved image.
8096        let replaced = match next_catalog
8097            .triggers
8098            .iter()
8099            .position(|t| t.trigger.name == trigger.name)
8100        {
8101            Some(idx) => next_catalog.triggers[idx]
8102                .trigger
8103                .replaced(trigger.clone(), epoch.0)?,
8104            None => {
8105                let mut next = trigger;
8106                next.created_epoch = epoch.0;
8107                next.updated_epoch = epoch.0;
8108                next
8109            }
8110        };
8111        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
8112            trigger: replaced.clone(),
8113        };
8114        self.apply_catalog_command_to(&mut next_catalog, command)?;
8115        next_catalog.db_epoch = epoch.0;
8116        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8117        Ok(replaced)
8118    }
8119
8120    pub fn drop_trigger(&self, name: &str) -> Result<()> {
8121        self.drop_trigger_with_epoch(name).map(|_| ())
8122    }
8123
8124    /// Drop one trigger and return the exact catalog publication epoch.
8125    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
8126        self.drop_triggers_with_epoch(&[name.to_string()])
8127    }
8128
8129    pub fn drop_trigger_with_epoch_controlled<F>(
8130        &self,
8131        name: &str,
8132        before_publish: F,
8133    ) -> Result<Epoch>
8134    where
8135        F: FnMut() -> Result<()>,
8136    {
8137        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
8138    }
8139
8140    /// Atomically drop several triggers in one catalog publication.
8141    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
8142        self.drop_triggers_with_epoch_inner(names, None, None)
8143    }
8144
8145    pub fn drop_triggers_with_epoch_controlled<F>(
8146        &self,
8147        names: &[String],
8148        mut before_publish: F,
8149    ) -> Result<Epoch>
8150    where
8151        F: FnMut() -> Result<()>,
8152    {
8153        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
8154    }
8155
8156    pub fn drop_triggers_with_epoch_as_controlled<F>(
8157        &self,
8158        names: &[String],
8159        principal: Option<&crate::auth::Principal>,
8160        mut before_publish: F,
8161    ) -> Result<Epoch>
8162    where
8163        F: FnMut() -> Result<()>,
8164    {
8165        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
8166    }
8167
8168    fn drop_triggers_with_epoch_inner(
8169        &self,
8170        names: &[String],
8171        principal: Option<&crate::auth::Principal>,
8172        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8173    ) -> Result<Epoch> {
8174        if names.is_empty() {
8175            return Err(MongrelError::InvalidArgument(
8176                "at least one trigger name is required".into(),
8177            ));
8178        }
8179        let commands: Vec<crate::catalog_cmds::CatalogCommand> = names
8180            .iter()
8181            .map(|name| crate::catalog_cmds::CatalogCommand::DropTrigger { name: name.clone() })
8182            .collect();
8183        for command in &commands {
8184            self.require_for(
8185                principal,
8186                &crate::catalog_cmds::required_permission(command),
8187            )?;
8188        }
8189        let _g = self.ddl_lock.lock();
8190        let _security_write = self.security_write()?;
8191        for command in &commands {
8192            self.require_for(
8193                principal,
8194                &crate::catalog_cmds::required_permission(command),
8195            )?;
8196        }
8197        // S1F-001: every name's command validates pure against the current
8198        // catalog first so a missing trigger surfaces before an epoch is
8199        // consumed, exactly like the legacy pre-bump check. A batch drop then
8200        // records one command per name (the emitting-layer expansion rule).
8201        {
8202            let cat = self.catalog.read();
8203            for command in &commands {
8204                crate::catalog_cmds::apply(&cat, command)?;
8205            }
8206        }
8207        let commit_lock = Arc::clone(&self.commit_lock);
8208        let _c = commit_lock.lock();
8209        let epoch = self.epoch.bump_assigned();
8210        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8211        let mut next_catalog = self.catalog.read().clone();
8212        for command in commands {
8213            self.apply_catalog_command_to(&mut next_catalog, command)?;
8214        }
8215        next_catalog.db_epoch = epoch.0;
8216        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8217        Ok(epoch)
8218    }
8219
8220    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
8221        self.catalog.read().external_tables.clone()
8222    }
8223
8224    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
8225        self.catalog
8226            .read()
8227            .external_tables
8228            .iter()
8229            .find(|t| t.name == name)
8230            .cloned()
8231    }
8232
8233    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
8234        self.create_external_table_inner(entry, None)
8235    }
8236
8237    pub fn create_external_table_controlled<F>(
8238        &self,
8239        entry: ExternalTableEntry,
8240        mut before_publish: F,
8241    ) -> Result<ExternalTableEntry>
8242    where
8243        F: FnMut() -> Result<()>,
8244    {
8245        self.create_external_table_inner(entry, Some(&mut before_publish))
8246    }
8247
8248    fn create_external_table_inner(
8249        &self,
8250        mut entry: ExternalTableEntry,
8251        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8252    ) -> Result<ExternalTableEntry> {
8253        self.require(&crate::auth::Permission::Ddl)?;
8254        let _g = self.ddl_lock.lock();
8255        let _security_write = self.security_write()?;
8256        self.require(&crate::auth::Permission::Ddl)?;
8257        entry.validate()?;
8258        {
8259            let cat = self.catalog.read();
8260            if cat.live(&entry.name).is_some()
8261                || cat.external_tables.iter().any(|t| t.name == entry.name)
8262            {
8263                return Err(MongrelError::InvalidArgument(format!(
8264                    "table {:?} already exists",
8265                    entry.name
8266                )));
8267            }
8268        }
8269        let commit_lock = Arc::clone(&self.commit_lock);
8270        let _c = commit_lock.lock();
8271        // A prior durable drop may have left connector state behind if its
8272        // cleanup failed or the process crashed. Never let a new table with
8273        // the same name inherit that stale state.
8274        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
8275        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
8276        let epoch = self.epoch.bump_assigned();
8277        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8278        entry.created_epoch = epoch.0;
8279        let mut next_catalog = self.catalog.read().clone();
8280        next_catalog.external_tables.push(entry.clone());
8281        next_catalog.db_epoch = epoch.0;
8282        self.publish_catalog_candidate_with_prelude(
8283            next_catalog,
8284            epoch,
8285            &mut _epoch_guard,
8286            before_publish,
8287            vec![(
8288                EXTERNAL_TABLE_ID,
8289                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
8290                    name: entry.name.clone(),
8291                    generation_epoch: epoch.0,
8292                }),
8293            )],
8294        )?;
8295        Ok(entry)
8296    }
8297
8298    pub fn drop_external_table(&self, name: &str) -> Result<()> {
8299        self.drop_external_table_with_epoch(name).map(|_| ())
8300    }
8301
8302    /// Drop an external table and return the exact catalog publication epoch.
8303    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
8304        self.drop_external_table_with_epoch_inner(name, None)
8305    }
8306
8307    pub fn drop_external_table_with_epoch_controlled<F>(
8308        &self,
8309        name: &str,
8310        mut before_publish: F,
8311    ) -> Result<Epoch>
8312    where
8313        F: FnMut() -> Result<()>,
8314    {
8315        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
8316    }
8317
8318    fn drop_external_table_with_epoch_inner(
8319        &self,
8320        name: &str,
8321        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8322    ) -> Result<Epoch> {
8323        self.require(&crate::auth::Permission::Ddl)?;
8324        let _g = self.ddl_lock.lock();
8325        let _security_write = self.security_write()?;
8326        self.require(&crate::auth::Permission::Ddl)?;
8327        let commit_lock = Arc::clone(&self.commit_lock);
8328        let _c = commit_lock.lock();
8329        let epoch = self.epoch.bump_assigned();
8330        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8331        let mut next_catalog = self.catalog.read().clone();
8332        let before = next_catalog.external_tables.len();
8333        next_catalog.external_tables.retain(|t| t.name != name);
8334        if next_catalog.external_tables.len() == before {
8335            return Err(MongrelError::NotFound(format!(
8336                "external table {name:?} not found"
8337            )));
8338        }
8339        next_catalog.db_epoch = epoch.0;
8340        self.publish_catalog_candidate_with_prelude(
8341            next_catalog,
8342            epoch,
8343            &mut _epoch_guard,
8344            before_publish,
8345            vec![(
8346                EXTERNAL_TABLE_ID,
8347                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
8348                    name: name.to_string(),
8349                    generation_epoch: epoch.0,
8350                }),
8351            )],
8352        )?;
8353        let state_dir = self.root.join(VTAB_DIR).join(name);
8354        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
8355            return Err(MongrelError::DurableCommit {
8356                epoch: epoch.0,
8357                message: format!(
8358                    "external table was dropped but connector-state cleanup failed: {error}"
8359                ),
8360            });
8361        }
8362        Ok(epoch)
8363    }
8364
8365    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
8366        let txn_id = self.alloc_txn_id()?;
8367        let (principal, catalog_bound) = self.transaction_principal_snapshot();
8368        self.commit_transaction_with_external_states(
8369            txn_id,
8370            self.epoch.visible(),
8371            Vec::new(),
8372            vec![(name.to_string(), state.to_vec())],
8373            Vec::new(),
8374            principal,
8375            catalog_bound,
8376            None,
8377            crate::txn::TxnCommitContext::internal(),
8378        )
8379        .map(|(epoch, _)| epoch)
8380    }
8381
8382    pub fn trigger_config(&self) -> TriggerConfig {
8383        use std::sync::atomic::Ordering;
8384        TriggerConfig {
8385            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
8386            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
8387            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
8388        }
8389    }
8390
8391    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
8392        use std::sync::atomic::Ordering;
8393        if config.max_depth == 0 {
8394            return Err(MongrelError::InvalidArgument(
8395                "trigger max_depth must be greater than 0".into(),
8396            ));
8397        }
8398        self.trigger_recursive
8399            .store(config.recursive_triggers, Ordering::Relaxed);
8400        self.trigger_max_depth
8401            .store(config.max_depth, Ordering::Relaxed);
8402        self.trigger_max_loop_iterations
8403            .store(config.max_loop_iterations, Ordering::Relaxed);
8404        Ok(())
8405    }
8406
8407    pub fn set_recursive_triggers(&self, recursive: bool) {
8408        use std::sync::atomic::Ordering;
8409        self.trigger_recursive.store(recursive, Ordering::Relaxed);
8410    }
8411
8412    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
8413    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
8414    /// as a low-latency wake-up.
8415    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
8416        self.notify.subscribe()
8417    }
8418
8419    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
8420        self.change_wake.subscribe()
8421    }
8422
8423    /// Reconstruct committed row changes from the retained shared WAL. Event
8424    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
8425    /// resumes before the oldest retained commit receives `gap = true` and
8426    /// must rebootstrap instead of silently skipping changes.
8427    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
8428        let control = crate::ExecutionControl::new(None);
8429        self.change_events_since_controlled(last_event_id, &control)
8430    }
8431
8432    /// Reconstruct committed changes with cooperative cancellation and bounds.
8433    pub fn change_events_since_controlled(
8434        &self,
8435        last_event_id: Option<&str>,
8436        control: &crate::ExecutionControl,
8437    ) -> Result<CdcBatch> {
8438        use crate::wal::Op;
8439
8440        control.checkpoint()?;
8441        let resume = match last_event_id {
8442            Some(id) => {
8443                let (epoch, index) = id.split_once(':').ok_or_else(|| {
8444                    MongrelError::InvalidArgument(format!(
8445                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
8446                    ))
8447                })?;
8448                Some((
8449                    epoch.parse::<u64>().map_err(|error| {
8450                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
8451                    })?,
8452                    index.parse::<u32>().map_err(|error| {
8453                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
8454                    })?,
8455                ))
8456            }
8457            None => None,
8458        };
8459
8460        let mut wal = self.shared_wal.lock();
8461        wal.group_sync()?;
8462        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
8463        let records = crate::wal::SharedWal::replay_with_dek_controlled(
8464            &self.root,
8465            wal_dek.as_ref(),
8466            control,
8467            CDC_MAX_WAL_RECORDS,
8468            CDC_MAX_WAL_REPLAY_BYTES,
8469        )?;
8470        drop(wal);
8471        control.checkpoint()?;
8472
8473        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
8474        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
8475        for (index, record) in records.iter().enumerate() {
8476            if index % 256 == 0 {
8477                control.checkpoint()?;
8478            }
8479            if let Op::TxnCommit { epoch, added_runs } = &record.op {
8480                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
8481            }
8482            if let Op::SpilledRows { table_id, rows } = &record.op {
8483                spilled_payloads
8484                    .entry((record.txn_id, *table_id))
8485                    .or_default()
8486                    .push(rows);
8487            }
8488        }
8489        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
8490        let current_epoch = self.epoch.committed().0;
8491        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
8492        let gap = resume.is_some_and(|(epoch, _)| {
8493            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
8494        });
8495        if gap {
8496            return Ok(CdcBatch {
8497                events: Vec::new(),
8498                current_epoch,
8499                earliest_epoch,
8500                gap: true,
8501            });
8502        }
8503
8504        let table_names: HashMap<u64, String> = self
8505            .catalog
8506            .read()
8507            .tables
8508            .iter()
8509            .map(|entry| (entry.table_id, entry.name.clone()))
8510            .collect();
8511        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
8512        let mut retained_bytes = 0_usize;
8513        for (index, record) in records.iter().enumerate() {
8514            if index % 256 == 0 {
8515                control.checkpoint()?;
8516            }
8517            if !commits.contains_key(&record.txn_id) {
8518                continue;
8519            }
8520            let Op::BeforeImage {
8521                table_id,
8522                row_id,
8523                row,
8524            } = &record.op
8525            else {
8526                continue;
8527            };
8528            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
8529                return Err(MongrelError::ResourceLimitExceeded {
8530                    resource: "CDC before-image bytes",
8531                    requested: row.len(),
8532                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
8533                });
8534            }
8535            let before: crate::memtable::Row = bincode::deserialize(row)?;
8536            if before_images.len() >= CDC_MAX_ROWS {
8537                return Err(MongrelError::ResourceLimitExceeded {
8538                    resource: "CDC before-image rows",
8539                    requested: before_images.len().saturating_add(1),
8540                    limit: CDC_MAX_ROWS,
8541                });
8542            }
8543            charge_cdc_bytes(
8544                &mut retained_bytes,
8545                cdc_row_storage_bytes(&before),
8546                "CDC retained bytes",
8547            )?;
8548            before_images.insert((record.txn_id, *table_id, row_id.0), before);
8549        }
8550        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
8551        let mut events = Vec::new();
8552        let mut decoded_rows = before_images.len();
8553        for (record_index, record) in records.iter().enumerate() {
8554            if record_index % 256 == 0 {
8555                control.checkpoint()?;
8556            }
8557            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
8558                continue;
8559            };
8560            let event = match &record.op {
8561                Op::Put { table_id, rows } => {
8562                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
8563                        return Err(MongrelError::ResourceLimitExceeded {
8564                            resource: "CDC inline row bytes",
8565                            requested: rows.len(),
8566                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
8567                        });
8568                    }
8569                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
8570                    decoded_rows = decoded_rows.saturating_add(rows.len());
8571                    if decoded_rows > CDC_MAX_ROWS {
8572                        return Err(MongrelError::ResourceLimitExceeded {
8573                            resource: "CDC decoded rows",
8574                            requested: decoded_rows,
8575                            limit: CDC_MAX_ROWS,
8576                        });
8577                    }
8578                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
8579                    let mut peak_bytes = retained_bytes;
8580                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
8581                    let data = serde_json::to_value(rows)
8582                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
8583                    Some((*table_id, "put", data, event_bytes))
8584                }
8585                Op::Delete { table_id, row_ids } => {
8586                    let before = row_ids
8587                        .iter()
8588                        .filter_map(|row_id| {
8589                            before_images
8590                                .get(&(record.txn_id, *table_id, row_id.0))
8591                                .cloned()
8592                        })
8593                        .collect::<Vec<_>>();
8594                    let event_bytes = cdc_rows_json_bytes(&before)
8595                        .saturating_add(
8596                            row_ids
8597                                .len()
8598                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
8599                        )
8600                        .saturating_add(512);
8601                    let mut peak_bytes = retained_bytes;
8602                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
8603                    Some((
8604                        *table_id,
8605                        "delete",
8606                        serde_json::json!({
8607                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
8608                            "before": before,
8609                        }),
8610                        event_bytes,
8611                    ))
8612                }
8613                Op::TruncateTable { table_id } => {
8614                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
8615                }
8616                _ => None,
8617            };
8618            if let Some((table_id, op, data, event_bytes)) = event {
8619                let index = operation_indices.entry(record.txn_id).or_insert(0);
8620                let event_position = (*commit_epoch, *index);
8621                *index = index.saturating_add(1);
8622                if resume.is_some_and(|position| event_position <= position) {
8623                    continue;
8624                }
8625                if events.len() >= CDC_MAX_EVENTS {
8626                    return Err(MongrelError::ResourceLimitExceeded {
8627                        resource: "CDC events",
8628                        requested: events.len().saturating_add(1),
8629                        limit: CDC_MAX_EVENTS,
8630                    });
8631                }
8632                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
8633                events.push(ChangeEvent {
8634                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
8635                    channel: "changes".into(),
8636                    table_id: Some(table_id),
8637                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
8638                    op: op.into(),
8639                    epoch: *commit_epoch,
8640                    txn_id: Some(record.txn_id),
8641                    message: None,
8642                    data: Some(data),
8643                });
8644            }
8645            if let Op::TxnCommit { added_runs, .. } = &record.op {
8646                for run in added_runs {
8647                    control.checkpoint()?;
8648                    let index = operation_indices.entry(record.txn_id).or_insert(0);
8649                    let event_position = (*commit_epoch, *index);
8650                    *index = index.saturating_add(1);
8651                    if resume.is_some_and(|position| event_position <= position) {
8652                        continue;
8653                    }
8654                    let mut rows = if let Some(payloads) =
8655                        spilled_payloads.get(&(record.txn_id, run.table_id))
8656                    {
8657                        let mut rows = Vec::new();
8658                        for payload in payloads {
8659                            control.checkpoint()?;
8660                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
8661                                return Err(MongrelError::ResourceLimitExceeded {
8662                                    resource: "CDC spilled row bytes",
8663                                    requested: payload.len(),
8664                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
8665                                });
8666                            }
8667                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
8668                            if decoded_rows
8669                                .saturating_add(rows.len())
8670                                .saturating_add(chunk.len())
8671                                > CDC_MAX_ROWS
8672                            {
8673                                return Err(MongrelError::ResourceLimitExceeded {
8674                                    resource: "CDC decoded rows",
8675                                    requested: decoded_rows
8676                                        .saturating_add(rows.len())
8677                                        .saturating_add(chunk.len()),
8678                                    limit: CDC_MAX_ROWS,
8679                                });
8680                            }
8681                            rows.extend(chunk);
8682                        }
8683                        rows
8684                    } else {
8685                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
8686                            return Ok(CdcBatch {
8687                                events: Vec::new(),
8688                                current_epoch,
8689                                earliest_epoch,
8690                                gap: true,
8691                            });
8692                        };
8693                        let table = handle.lock();
8694                        let mut reader = match table.open_reader(run.run_id) {
8695                            Ok(reader) => reader,
8696                            Err(_) => {
8697                                return Ok(CdcBatch {
8698                                    events: Vec::new(),
8699                                    current_epoch,
8700                                    earliest_epoch,
8701                                    gap: true,
8702                                })
8703                            }
8704                        };
8705                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
8706                        let rows = reader.all_rows_controlled(control, remaining)?;
8707                        drop(reader);
8708                        drop(table);
8709                        rows
8710                    };
8711                    for row in &mut rows {
8712                        row.committed_epoch = Epoch(*commit_epoch);
8713                    }
8714                    decoded_rows = decoded_rows.saturating_add(rows.len());
8715                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
8716                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
8717                    if events.len() >= CDC_MAX_EVENTS {
8718                        return Err(MongrelError::ResourceLimitExceeded {
8719                            resource: "CDC events",
8720                            requested: events.len().saturating_add(1),
8721                            limit: CDC_MAX_EVENTS,
8722                        });
8723                    }
8724                    events.push(ChangeEvent {
8725                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
8726                        channel: "changes".into(),
8727                        table_id: Some(run.table_id),
8728                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
8729                        op: "put_run".into(),
8730                        epoch: *commit_epoch,
8731                        txn_id: Some(record.txn_id),
8732                        message: None,
8733                        data: Some(serde_json::json!({
8734                            "run_id": run.run_id.to_string(),
8735                            "row_count": run.row_count,
8736                            "min_row_id": run.min_row_id,
8737                            "max_row_id": run.max_row_id,
8738                            "rows": rows,
8739                        })),
8740                    });
8741                }
8742            }
8743        }
8744        control.checkpoint()?;
8745        Ok(CdcBatch {
8746            events,
8747            current_epoch,
8748            earliest_epoch,
8749            gap: false,
8750        })
8751    }
8752
8753    /// Publish a notification message on a named channel. Reaches all active
8754    /// subscribers (daemon `/events`, application listeners).
8755    pub fn notify(&self, channel: &str, message: Option<String>) {
8756        let _ = self.notify.send(ChangeEvent {
8757            id: None,
8758            channel: channel.to_string(),
8759            table_id: None,
8760            table: String::new(),
8761            op: "notify".into(),
8762            epoch: self.epoch.visible().0,
8763            txn_id: None,
8764            message,
8765            data: None,
8766        });
8767    }
8768
8769    pub fn call_procedure(
8770        &self,
8771        name: &str,
8772        args: HashMap<String, crate::Value>,
8773    ) -> Result<ProcedureCallResult> {
8774        self.call_procedure_as(name, args, None)
8775    }
8776
8777    pub fn call_procedure_as(
8778        &self,
8779        name: &str,
8780        args: HashMap<String, crate::Value>,
8781        principal: Option<&crate::auth::Principal>,
8782    ) -> Result<ProcedureCallResult> {
8783        let control = crate::ExecutionControl::new(None);
8784        self.call_procedure_as_controlled(name, args, principal, &control, || true)
8785    }
8786
8787    /// Execute only the exact procedure revision previously authorized by the
8788    /// caller. A dropped or replaced definition fails closed.
8789    #[doc(hidden)]
8790    pub fn call_procedure_as_bound(
8791        &self,
8792        expected: &StoredProcedure,
8793        args: HashMap<String, crate::Value>,
8794        principal: Option<&crate::auth::Principal>,
8795    ) -> Result<ProcedureCallResult> {
8796        self.require_for(principal, &crate::auth::Permission::All)?;
8797        let procedure = self.procedure(&expected.name).ok_or_else(|| {
8798            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
8799        })?;
8800        if &procedure != expected {
8801            return Err(MongrelError::Conflict(format!(
8802                "procedure {:?} changed after request authorization",
8803                expected.name
8804            )));
8805        }
8806        let control = crate::ExecutionControl::new(None);
8807        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
8808    }
8809
8810    /// Execute a procedure with cooperative cancellation during preparation.
8811    /// `before_commit` runs after every procedure step has succeeded and
8812    /// immediately before a write procedure commits. Returning `false` aborts
8813    /// the transaction without publishing it.
8814    #[doc(hidden)]
8815    pub fn call_procedure_as_controlled<F>(
8816        &self,
8817        name: &str,
8818        args: HashMap<String, crate::Value>,
8819        principal: Option<&crate::auth::Principal>,
8820        control: &crate::ExecutionControl,
8821        before_commit: F,
8822    ) -> Result<ProcedureCallResult>
8823    where
8824        F: FnOnce() -> bool,
8825    {
8826        // v1 requires ALL to call procedures on a require_auth database; a
8827        // finer SECURITY DEFINER-style marker is a future extension (spec §9
8828        // decision 1).
8829        self.require_for(principal, &crate::auth::Permission::All)?;
8830        let procedure = self
8831            .procedure(name)
8832            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
8833        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
8834    }
8835
8836    fn execute_procedure_as_controlled<F>(
8837        &self,
8838        procedure: StoredProcedure,
8839        args: HashMap<String, crate::Value>,
8840        principal: Option<&crate::auth::Principal>,
8841        control: &crate::ExecutionControl,
8842        before_commit: F,
8843    ) -> Result<ProcedureCallResult>
8844    where
8845        F: FnOnce() -> bool,
8846    {
8847        let args = bind_procedure_args(&procedure, args)?;
8848        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
8849        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
8850        if has_writes {
8851            let mut tx = self.begin_as(principal.cloned());
8852            let run = (|| {
8853                for (step_index, step) in procedure.body.steps.iter().enumerate() {
8854                    if step_index % 256 == 0 {
8855                        control.checkpoint()?;
8856                    }
8857                    let output = self.execute_procedure_step(
8858                        step,
8859                        &args,
8860                        &outputs,
8861                        Some(&mut tx),
8862                        principal,
8863                        Some(control),
8864                    )?;
8865                    outputs.insert(step.id().to_string(), output);
8866                }
8867                control.checkpoint()?;
8868                eval_return_output(&procedure.body.return_value, &args, &outputs)
8869            })();
8870            match run {
8871                Ok(output) => {
8872                    control.checkpoint()?;
8873                    if !before_commit() {
8874                        tx.rollback();
8875                        return Err(MongrelError::Cancelled);
8876                    }
8877                    let epoch = tx.commit()?.0;
8878                    Ok(ProcedureCallResult {
8879                        epoch: Some(epoch),
8880                        output,
8881                    })
8882                }
8883                Err(e) => {
8884                    tx.rollback();
8885                    Err(e)
8886                }
8887            }
8888        } else {
8889            for (step_index, step) in procedure.body.steps.iter().enumerate() {
8890                if step_index % 256 == 0 {
8891                    control.checkpoint()?;
8892                }
8893                let output = self.execute_procedure_step(
8894                    step,
8895                    &args,
8896                    &outputs,
8897                    None,
8898                    principal,
8899                    Some(control),
8900                )?;
8901                outputs.insert(step.id().to_string(), output);
8902            }
8903            control.checkpoint()?;
8904            Ok(ProcedureCallResult {
8905                epoch: None,
8906                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
8907            })
8908        }
8909    }
8910
8911    fn execute_procedure_step(
8912        &self,
8913        step: &ProcedureStep,
8914        args: &HashMap<String, crate::Value>,
8915        outputs: &HashMap<String, ProcedureCallOutput>,
8916        tx: Option<&mut crate::txn::Transaction<'_>>,
8917        principal: Option<&crate::auth::Principal>,
8918        control: Option<&crate::ExecutionControl>,
8919    ) -> Result<ProcedureCallOutput> {
8920        if let Some(control) = control {
8921            control.checkpoint()?;
8922        }
8923        match step {
8924            ProcedureStep::NativeQuery {
8925                table,
8926                conditions,
8927                projection,
8928                limit,
8929                ..
8930            } => {
8931                let mut q = crate::Query::new();
8932                for condition in conditions {
8933                    q = q.and(eval_condition(condition, args, outputs)?);
8934                }
8935                let fallback_control = crate::ExecutionControl::new(None);
8936                let query_control = control.unwrap_or(&fallback_control);
8937                let mut rows = self.query_for_principal_controlled(
8938                    table,
8939                    &q,
8940                    projection.as_deref(),
8941                    principal,
8942                    false,
8943                    query_control,
8944                )?;
8945                if let Some(limit) = limit {
8946                    rows.truncate(*limit);
8947                }
8948                let mut output = Vec::with_capacity(rows.len());
8949                for (row_index, row) in rows.into_iter().enumerate() {
8950                    if row_index % 256 == 0 {
8951                        if let Some(control) = control {
8952                            control.checkpoint()?;
8953                        }
8954                    }
8955                    output.push(ProcedureCallRow {
8956                        row_id: Some(row.row_id),
8957                        columns: row.columns,
8958                    });
8959                }
8960                Ok(ProcedureCallOutput::Rows(output))
8961            }
8962            ProcedureStep::Put {
8963                table,
8964                cells,
8965                returning,
8966                ..
8967            } => {
8968                let tx = tx.ok_or_else(|| {
8969                    MongrelError::InvalidArgument(
8970                        "write procedure step requires a transaction".into(),
8971                    )
8972                })?;
8973                let cells = eval_cells(cells, args, outputs)?;
8974                if *returning {
8975                    let out = tx.put_returning(table, cells)?;
8976                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
8977                        row_id: None,
8978                        columns: out.row.columns.into_iter().collect(),
8979                    }))
8980                } else {
8981                    tx.put(table, cells)?;
8982                    Ok(ProcedureCallOutput::Null)
8983                }
8984            }
8985            ProcedureStep::Upsert {
8986                table,
8987                cells,
8988                update_cells,
8989                returning,
8990                ..
8991            } => {
8992                let tx = tx.ok_or_else(|| {
8993                    MongrelError::InvalidArgument(
8994                        "write procedure step requires a transaction".into(),
8995                    )
8996                })?;
8997                let cells = eval_cells(cells, args, outputs)?;
8998                let action = match update_cells {
8999                    Some(update_cells) => {
9000                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
9001                    }
9002                    None => crate::UpsertAction::DoNothing,
9003                };
9004                let out = tx.upsert(table, cells, action)?;
9005                if *returning {
9006                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
9007                        row_id: None,
9008                        columns: out.row.columns.into_iter().collect(),
9009                    }))
9010                } else {
9011                    Ok(ProcedureCallOutput::Null)
9012                }
9013            }
9014            ProcedureStep::DeleteByPk { table, pk, .. } => {
9015                let tx = tx.ok_or_else(|| {
9016                    MongrelError::InvalidArgument(
9017                        "write procedure step requires a transaction".into(),
9018                    )
9019                })?;
9020                let pk = eval_value(pk, args, outputs)?;
9021                let handle = self.table(table)?;
9022                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
9023                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
9024                })?;
9025                tx.delete(table, row_id)?;
9026                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
9027            }
9028            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
9029                "DeleteRows procedure step is not supported by the core executor yet".into(),
9030            )),
9031            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
9032                "SqlQuery procedure step must be executed by mongreldb-query".into(),
9033            )),
9034        }
9035    }
9036
9037    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
9038        let cat = self.catalog.read();
9039        for step in &procedure.body.steps {
9040            let Some(table_name) = step.table() else {
9041                continue;
9042            };
9043            let schema = &cat
9044                .live(table_name)
9045                .ok_or_else(|| {
9046                    MongrelError::InvalidArgument(format!(
9047                        "procedure {:?} references unknown table {table_name:?}",
9048                        procedure.name
9049                    ))
9050                })?
9051                .schema;
9052            match step {
9053                ProcedureStep::NativeQuery {
9054                    conditions,
9055                    projection,
9056                    ..
9057                } => {
9058                    for condition in conditions {
9059                        validate_condition_columns(condition, schema)?;
9060                    }
9061                    if let Some(projection) = projection {
9062                        for id in projection {
9063                            validate_column_id(*id, schema)?;
9064                        }
9065                    }
9066                }
9067                ProcedureStep::Put { cells, .. } => {
9068                    for cell in cells {
9069                        validate_column_id(cell.column_id, schema)?;
9070                    }
9071                }
9072                ProcedureStep::Upsert {
9073                    cells,
9074                    update_cells,
9075                    ..
9076                } => {
9077                    for cell in cells {
9078                        validate_column_id(cell.column_id, schema)?;
9079                    }
9080                    if let Some(update_cells) = update_cells {
9081                        for cell in update_cells {
9082                            validate_column_id(cell.column_id, schema)?;
9083                        }
9084                    }
9085                }
9086                ProcedureStep::DeleteByPk { .. } => {
9087                    if schema.primary_key().is_none() {
9088                        return Err(MongrelError::InvalidArgument(format!(
9089                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
9090                            procedure.name
9091                        )));
9092                    }
9093                }
9094                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
9095            }
9096        }
9097        Ok(())
9098    }
9099
9100    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
9101        let cat = self.catalog.read();
9102        let target_schema = match &trigger.target {
9103            TriggerTarget::Table(target_name) => cat
9104                .live(target_name)
9105                .ok_or_else(|| {
9106                    MongrelError::InvalidArgument(format!(
9107                        "trigger {:?} references unknown target table {target_name:?}",
9108                        trigger.name
9109                    ))
9110                })?
9111                .schema
9112                .clone(),
9113            TriggerTarget::View(_) => Schema {
9114                columns: trigger.target_columns.clone(),
9115                ..Schema::default()
9116            },
9117        };
9118        for col in &trigger.update_of {
9119            if target_schema.column(col).is_none() {
9120                return Err(MongrelError::InvalidArgument(format!(
9121                    "trigger {:?} UPDATE OF references unknown column {col:?}",
9122                    trigger.name
9123                )));
9124            }
9125        }
9126        if let Some(expr) = &trigger.when {
9127            validate_trigger_expr(expr, &target_schema, trigger.event)?;
9128        }
9129        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
9130        for step in &trigger.program.steps {
9131            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
9132            {
9133                return Err(MongrelError::InvalidArgument(
9134                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
9135                ));
9136            }
9137            validate_trigger_step(
9138                step,
9139                &cat,
9140                &target_schema,
9141                trigger.event,
9142                &mut select_schemas,
9143            )?;
9144        }
9145        Ok(())
9146    }
9147
9148    /// Begin a new transaction reading at the current visible epoch.
9149    pub fn begin(&self) -> crate::txn::Transaction<'_> {
9150        self.begin_with_isolation(crate::txn::IsolationLevel::default())
9151    }
9152
9153    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
9154        let principal = self.principal.read().clone();
9155        let catalog_bound = principal.as_ref().is_some_and(|principal| {
9156            let catalog = self.catalog.read();
9157            catalog.require_auth || principal.user_id != 0
9158        });
9159        (principal, catalog_bound)
9160    }
9161
9162    pub fn begin_as(
9163        &self,
9164        principal: Option<crate::auth::Principal>,
9165    ) -> crate::txn::Transaction<'_> {
9166        let catalog_bound = principal.as_ref().is_some_and(|principal| {
9167            let catalog = self.catalog.read();
9168            catalog.require_auth || principal.user_id != 0
9169        });
9170        let txn_id = self.alloc_txn_id();
9171        let read = Snapshot::at(self.epoch.visible());
9172        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
9173            .with_principal(principal, catalog_bound)
9174    }
9175
9176    /// Begin a transaction with a specific isolation level.
9177    pub fn begin_with_isolation(
9178        &self,
9179        level: crate::txn::IsolationLevel,
9180    ) -> crate::txn::Transaction<'_> {
9181        let txn_id = self.alloc_txn_id();
9182        // Every level pins the current visible epoch at begin; ReadCommitted
9183        // re-pins per statement inside the transaction (S1B-002).
9184        let read = Snapshot::at(self.epoch.visible());
9185        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9186        crate::txn::Transaction::new(self, txn_id, read, level)
9187            .with_principal(principal, catalog_bound)
9188    }
9189
9190    /// Begin a transaction whose trigger programs may route external-table DML
9191    /// through an application/query-layer module bridge.
9192    pub fn begin_with_external_trigger_bridge<'a>(
9193        &'a self,
9194        bridge: &'a dyn ExternalTriggerBridge,
9195    ) -> crate::txn::Transaction<'a> {
9196        let txn_id = self.alloc_txn_id();
9197        let read = Snapshot::at(self.epoch.visible());
9198        let (principal, catalog_bound) = self.transaction_principal_snapshot();
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    pub fn begin_with_external_trigger_bridge_as<'a>(
9205        &'a self,
9206        bridge: &'a dyn ExternalTriggerBridge,
9207        principal: Option<crate::auth::Principal>,
9208    ) -> crate::txn::Transaction<'a> {
9209        let catalog_bound = principal.as_ref().is_some_and(|principal| {
9210            let catalog = self.catalog.read();
9211            catalog.require_auth || principal.user_id != 0
9212        });
9213        let txn_id = self.alloc_txn_id();
9214        let read = Snapshot::at(self.epoch.visible());
9215        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
9216            .with_external_trigger_bridge(bridge)
9217            .with_principal(principal, catalog_bound)
9218    }
9219
9220    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
9221    pub fn transaction<T>(
9222        &self,
9223        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9224    ) -> Result<T> {
9225        let mut tx = self.begin();
9226        match f(&mut tx) {
9227            Ok(out) => {
9228                tx.commit()?;
9229                Ok(out)
9230            }
9231            Err(e) => {
9232                tx.rollback();
9233                Err(e)
9234            }
9235        }
9236    }
9237
9238    pub fn transaction_with_row_ids<T>(
9239        &self,
9240        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9241    ) -> Result<(T, Vec<RowId>)> {
9242        let mut tx = self.begin();
9243        match f(&mut tx) {
9244            Ok(output) => {
9245                let (_, row_ids) = tx.commit_with_row_ids()?;
9246                Ok((output, row_ids))
9247            }
9248            Err(error) => {
9249                tx.rollback();
9250                Err(error)
9251            }
9252        }
9253    }
9254
9255    pub fn transaction_for_current_principal<T>(
9256        &self,
9257        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9258    ) -> Result<T> {
9259        if self.principal.read().is_some() {
9260            self.refresh_principal()?;
9261        }
9262        let mut transaction = self.begin_as(self.principal.read().clone());
9263        match f(&mut transaction) {
9264            Ok(output) => {
9265                transaction.commit()?;
9266                Ok(output)
9267            }
9268            Err(error) => {
9269                transaction.rollback();
9270                Err(error)
9271            }
9272        }
9273    }
9274
9275    pub fn transaction_for_current_principal_with_epoch<T>(
9276        &self,
9277        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9278    ) -> Result<(Epoch, T)> {
9279        if self.principal.read().is_some() {
9280            self.refresh_principal()?;
9281        }
9282        let mut transaction = self.begin_as(self.principal.read().clone());
9283        match f(&mut transaction) {
9284            Ok(output) => {
9285                let epoch = transaction.commit()?;
9286                Ok((epoch, output))
9287            }
9288            Err(error) => {
9289                transaction.rollback();
9290                Err(error)
9291            }
9292        }
9293    }
9294
9295    pub fn transaction_with_row_ids_for_current_principal<T>(
9296        &self,
9297        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9298    ) -> Result<(T, Vec<RowId>)> {
9299        if self.principal.read().is_some() {
9300            self.refresh_principal()?;
9301        }
9302        let mut transaction = self.begin_as(self.principal.read().clone());
9303        match f(&mut transaction) {
9304            Ok(output) => {
9305                let (_, row_ids) = transaction.commit_with_row_ids()?;
9306                Ok((output, row_ids))
9307            }
9308            Err(error) => {
9309                transaction.rollback();
9310                Err(error)
9311            }
9312        }
9313    }
9314
9315    /// Run `f` in a transaction with an external-trigger bridge; commit on
9316    /// `Ok`, rollback on `Err`.
9317    pub fn transaction_with_external_trigger_bridge<'a, T>(
9318        &'a self,
9319        bridge: &'a dyn ExternalTriggerBridge,
9320        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9321    ) -> Result<T> {
9322        let mut tx = self.begin_with_external_trigger_bridge(bridge);
9323        match f(&mut tx) {
9324            Ok(out) => {
9325                tx.commit()?;
9326                Ok(out)
9327            }
9328            Err(e) => {
9329                tx.rollback();
9330                Err(e)
9331            }
9332        }
9333    }
9334
9335    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
9336        &'a self,
9337        bridge: &'a dyn ExternalTriggerBridge,
9338        principal: Option<crate::auth::Principal>,
9339        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
9340    ) -> Result<T> {
9341        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
9342        match f(&mut tx) {
9343            Ok(output) => {
9344                tx.commit()?;
9345                Ok(output)
9346            }
9347            Err(error) => {
9348                tx.rollback();
9349                Err(error)
9350            }
9351        }
9352    }
9353
9354    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
9355    /// `Transaction::new` so registration happens **before** any read.
9356    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
9357        self.active_txns.register(epoch)
9358    }
9359
9360    fn fill_auto_increment_for_staging(
9361        &self,
9362        txn_id: u64,
9363        staging: &mut [(u64, crate::txn::Staged)],
9364        control: Option<&crate::ExecutionControl>,
9365    ) -> Result<()> {
9366        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9367        for (index, (table_id, staged)) in staging.iter().enumerate() {
9368            commit_prepare_checkpoint(control, index)?;
9369            if matches!(staged, crate::txn::Staged::Put(_)) {
9370                puts_by_table.entry(*table_id).or_default().push(index);
9371            }
9372        }
9373
9374        // S1B-003: sequence allocation serializes on one Exclusive barrier per
9375        // staged table whose puts actually ALLOCATE an auto-increment value
9376        // (absent/Null column — explicit values only advance the counter and
9377        // must not serialize), held until the transaction ends so allocated
9378        // values map monotonically onto commit order. The barrier is acquired
9379        // before the table lock (never the reverse).
9380        {
9381            let mut barrier_tables: Vec<u64> = puts_by_table
9382                .iter()
9383                .filter(|(table_id, indexes)| {
9384                    indexes.iter().any(|index| {
9385                        matches!(
9386                            &staging[*index].1,
9387                            crate::txn::Staged::Put(cells)
9388                                if self.table_auto_inc_would_allocate(**table_id, cells)
9389                        )
9390                    })
9391                })
9392                .map(|(table_id, _)| *table_id)
9393                .collect();
9394            barrier_tables.sort_unstable();
9395            for table_id in barrier_tables {
9396                self.acquire_txn_lock(
9397                    txn_id,
9398                    crate::locks::LockKey::sequence_barrier(&format!("auto_inc:{table_id}")),
9399                    crate::locks::LockMode::Exclusive,
9400                    control,
9401                )?;
9402            }
9403        }
9404
9405        let tables = self.tables.read();
9406        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
9407            commit_prepare_checkpoint(control, table_index)?;
9408            if let Some(handle) = tables.get(&table_id) {
9409                #[cfg(test)]
9410                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9411                let mut t = handle.lock();
9412                for (fill_index, index) in indexes.into_iter().enumerate() {
9413                    commit_prepare_checkpoint(control, fill_index)?;
9414                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
9415                        t.fill_auto_inc(cells)?;
9416                    }
9417                }
9418            }
9419        }
9420        Ok(())
9421    }
9422
9423    fn expand_table_triggers(
9424        &self,
9425        txn_id: u64,
9426        staging: &mut Vec<(u64, crate::txn::Staged)>,
9427        read_epoch: Epoch,
9428        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9429        external_states: &mut Vec<(String, Vec<u8>)>,
9430        control: Option<&crate::ExecutionControl>,
9431    ) -> Result<()> {
9432        commit_prepare_checkpoint(control, 0)?;
9433        let mut external_writes = Vec::new();
9434        let config = self.trigger_config();
9435        if config.recursive_triggers {
9436            let chunk = std::mem::take(staging);
9437            let stacks = vec![Vec::new(); chunk.len()];
9438            *staging = self.expand_trigger_chunk(
9439                txn_id,
9440                chunk,
9441                stacks,
9442                read_epoch,
9443                0,
9444                config.max_depth,
9445                &mut external_writes,
9446                &config,
9447                control,
9448            )?;
9449            self.apply_external_trigger_writes(
9450                external_writes,
9451                external_trigger_bridge,
9452                external_states,
9453                staging,
9454                control,
9455            )?;
9456            return Ok(());
9457        }
9458
9459        let mut expansion =
9460            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
9461        if !expansion.before.is_empty() {
9462            let mut final_staging = expansion.before;
9463            final_staging.extend(filter_ignored_staging(
9464                std::mem::take(staging),
9465                &expansion.ignored_indices,
9466            ));
9467            *staging = final_staging;
9468        } else if !expansion.ignored_indices.is_empty() {
9469            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
9470        }
9471        staging.append(&mut expansion.after);
9472        external_writes.append(&mut expansion.before_external);
9473        external_writes.append(&mut expansion.after_external);
9474        self.apply_external_trigger_writes(
9475            external_writes,
9476            external_trigger_bridge,
9477            external_states,
9478            staging,
9479            control,
9480        )?;
9481        Ok(())
9482    }
9483
9484    #[allow(clippy::too_many_arguments)]
9485    fn expand_trigger_chunk(
9486        &self,
9487        txn_id: u64,
9488        mut chunk: Vec<(u64, crate::txn::Staged)>,
9489        stacks: Vec<Vec<String>>,
9490        read_epoch: Epoch,
9491        depth: u32,
9492        max_depth: u32,
9493        external_writes: &mut Vec<ExternalTriggerWrite>,
9494        config: &TriggerConfig,
9495        control: Option<&crate::ExecutionControl>,
9496    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
9497        if chunk.is_empty() {
9498            return Ok(Vec::new());
9499        }
9500        commit_prepare_checkpoint(control, 0)?;
9501        self.fill_auto_increment_for_staging(txn_id, &mut chunk, control)?;
9502        let expansion = self.expand_table_triggers_once(
9503            &mut chunk,
9504            read_epoch,
9505            Some(&stacks),
9506            config,
9507            control,
9508        )?;
9509        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
9510            let stack = expansion
9511                .before_stacks
9512                .first()
9513                .or_else(|| expansion.after_stacks.first())
9514                .cloned()
9515                .unwrap_or_default();
9516            return Err(MongrelError::TriggerValidation(format!(
9517                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
9518                Self::format_trigger_stack(&stack)
9519            )));
9520        }
9521
9522        let mut out = Vec::new();
9523        external_writes.extend(expansion.before_external);
9524        out.extend(self.expand_trigger_chunk(
9525            txn_id,
9526            expansion.before,
9527            expansion.before_stacks,
9528            read_epoch,
9529            depth + 1,
9530            max_depth,
9531            external_writes,
9532            config,
9533            control,
9534        )?);
9535        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
9536        external_writes.extend(expansion.after_external);
9537        out.extend(self.expand_trigger_chunk(
9538            txn_id,
9539            expansion.after,
9540            expansion.after_stacks,
9541            read_epoch,
9542            depth + 1,
9543            max_depth,
9544            external_writes,
9545            config,
9546            control,
9547        )?);
9548        Ok(out)
9549    }
9550
9551    fn apply_external_trigger_writes(
9552        &self,
9553        writes: Vec<ExternalTriggerWrite>,
9554        bridge: Option<&dyn ExternalTriggerBridge>,
9555        external_states: &mut Vec<(String, Vec<u8>)>,
9556        staging: &mut Vec<(u64, crate::txn::Staged)>,
9557        control: Option<&crate::ExecutionControl>,
9558    ) -> Result<()> {
9559        if writes.is_empty() {
9560            return Ok(());
9561        }
9562        let bridge = bridge.ok_or_else(|| {
9563            MongrelError::TriggerValidation(
9564                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
9565            )
9566        })?;
9567        for (write_index, write) in writes.into_iter().enumerate() {
9568            commit_prepare_checkpoint(control, write_index)?;
9569            let table = write.table().to_string();
9570            let entry = self.external_table(&table).ok_or_else(|| {
9571                MongrelError::NotFound(format!("external table {table:?} not found"))
9572            })?;
9573            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
9574            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
9575            external_states.push((table, result.state));
9576            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
9577                commit_prepare_checkpoint(control, base_index)?;
9578                match base_write {
9579                    ExternalTriggerBaseWrite::Put { table, cells } => {
9580                        let table_id = self.table_id(&table)?;
9581                        staging.push((table_id, crate::txn::Staged::Put(cells)));
9582                    }
9583                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
9584                        let table_id = self.table_id(&table)?;
9585                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
9586                    }
9587                }
9588            }
9589        }
9590        dedup_external_states_in_place(external_states);
9591        Ok(())
9592    }
9593
9594    fn expand_table_triggers_once(
9595        &self,
9596        staging: &mut Vec<(u64, crate::txn::Staged)>,
9597        read_epoch: Epoch,
9598        trigger_stacks: Option<&[Vec<String>]>,
9599        config: &TriggerConfig,
9600        control: Option<&crate::ExecutionControl>,
9601    ) -> Result<TriggerExpansion> {
9602        commit_prepare_checkpoint(control, 0)?;
9603        let triggers: Vec<StoredTrigger> = self
9604            .catalog
9605            .read()
9606            .triggers
9607            .iter()
9608            .filter(|entry| {
9609                entry.trigger.enabled
9610                    && matches!(
9611                        entry.trigger.timing,
9612                        TriggerTiming::Before | TriggerTiming::After
9613                    )
9614                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
9615            })
9616            .map(|entry| entry.trigger.clone())
9617            .collect();
9618        if triggers.is_empty() || staging.is_empty() {
9619            return Ok(TriggerExpansion::default());
9620        }
9621
9622        let before_triggers = triggers
9623            .iter()
9624            .filter(|trigger| trigger.timing == TriggerTiming::Before)
9625            .cloned()
9626            .collect::<Vec<_>>();
9627        let after_triggers = triggers
9628            .iter()
9629            .filter(|trigger| trigger.timing == TriggerTiming::After)
9630            .cloned()
9631            .collect::<Vec<_>>();
9632
9633        let mut before_added = Vec::new();
9634        let mut before_stacks = Vec::new();
9635        let mut before_external = Vec::new();
9636        let mut ignored_indices = std::collections::BTreeSet::new();
9637        if !before_triggers.is_empty() {
9638            let before_events =
9639                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
9640            let mut out = TriggerProgramOutput {
9641                added: &mut before_added,
9642                added_stacks: &mut before_stacks,
9643                added_external: &mut before_external,
9644                ignored_indices: &mut ignored_indices,
9645            };
9646            self.execute_triggers_for_events(
9647                &before_triggers,
9648                &before_events,
9649                Some(staging),
9650                &mut out,
9651                config,
9652                read_epoch,
9653                control,
9654            )?;
9655        }
9656
9657        let after_events = if after_triggers.is_empty() {
9658            Vec::new()
9659        } else {
9660            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
9661                .into_iter()
9662                .filter(|event| {
9663                    !event
9664                        .op_indices
9665                        .iter()
9666                        .any(|idx| ignored_indices.contains(idx))
9667                })
9668                .collect()
9669        };
9670
9671        let mut after_added = Vec::new();
9672        let mut after_stacks = Vec::new();
9673        let mut after_external = Vec::new();
9674        let mut out = TriggerProgramOutput {
9675            added: &mut after_added,
9676            added_stacks: &mut after_stacks,
9677            added_external: &mut after_external,
9678            ignored_indices: &mut ignored_indices,
9679        };
9680        self.execute_triggers_for_events(
9681            &after_triggers,
9682            &after_events,
9683            None,
9684            &mut out,
9685            config,
9686            read_epoch,
9687            control,
9688        )?;
9689        Ok(TriggerExpansion {
9690            before: before_added,
9691            before_stacks,
9692            before_external,
9693            after: after_added,
9694            after_stacks,
9695            after_external,
9696            ignored_indices,
9697        })
9698    }
9699
9700    #[allow(clippy::too_many_arguments)]
9701    fn execute_triggers_for_events(
9702        &self,
9703        triggers: &[StoredTrigger],
9704        events: &[WriteEvent],
9705        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
9706        out: &mut TriggerProgramOutput<'_>,
9707        config: &TriggerConfig,
9708        read_epoch: Epoch,
9709        control: Option<&crate::ExecutionControl>,
9710    ) -> Result<()> {
9711        let mut checkpoint_index = 0_usize;
9712        for event in events {
9713            for trigger in triggers {
9714                commit_prepare_checkpoint(control, checkpoint_index)?;
9715                checkpoint_index += 1;
9716                if event
9717                    .op_indices
9718                    .iter()
9719                    .any(|idx| out.ignored_indices.contains(idx))
9720                {
9721                    break;
9722                }
9723                let matches = {
9724                    let cat = self.catalog.read();
9725                    trigger_matches_event(trigger, event, &cat)?
9726                };
9727                if !matches {
9728                    continue;
9729                }
9730                if let Some(when) = &trigger.when {
9731                    if !eval_trigger_expr(when, event)? {
9732                        continue;
9733                    }
9734                }
9735                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
9736                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
9737                    return Err(MongrelError::TriggerValidation(format!(
9738                        "trigger recursion cycle detected; trigger stack: {}",
9739                        Self::format_trigger_stack(&trigger_stack)
9740                    )));
9741                }
9742                let outcome = match staging.as_mut() {
9743                    Some(staging) => self.execute_trigger_program(
9744                        trigger,
9745                        event,
9746                        Some(&mut **staging),
9747                        out,
9748                        &trigger_stack,
9749                        config,
9750                        read_epoch,
9751                        control,
9752                    )?,
9753                    None => self.execute_trigger_program(
9754                        trigger,
9755                        event,
9756                        None,
9757                        out,
9758                        &trigger_stack,
9759                        config,
9760                        read_epoch,
9761                        control,
9762                    )?,
9763                };
9764                if outcome == TriggerProgramOutcome::Ignore {
9765                    out.ignored_indices.extend(event.op_indices.iter().copied());
9766                    break;
9767                }
9768            }
9769        }
9770        Ok(())
9771    }
9772
9773    fn trigger_events_for_staging(
9774        &self,
9775        staging: &[(u64, crate::txn::Staged)],
9776        read_epoch: Epoch,
9777        trigger_stacks: Option<&[Vec<String>]>,
9778        control: Option<&crate::ExecutionControl>,
9779    ) -> Result<Vec<WriteEvent>> {
9780        use crate::txn::Staged;
9781        use std::collections::{HashMap, VecDeque};
9782
9783        let snapshot = Snapshot::at(read_epoch);
9784        let cat = self.catalog.read();
9785        let mut table_names = HashMap::new();
9786        let mut table_schemas = HashMap::new();
9787        for entry in cat
9788            .tables
9789            .iter()
9790            .filter(|entry| matches!(entry.state, TableState::Live))
9791        {
9792            table_names.insert(entry.table_id, entry.name.clone());
9793            table_schemas.insert(entry.table_id, entry.schema.clone());
9794        }
9795        drop(cat);
9796
9797        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
9798        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
9799        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
9800
9801        for (idx, (table_id, staged)) in staging.iter().enumerate() {
9802            commit_prepare_checkpoint(control, idx)?;
9803            let Some(schema) = table_schemas.get(table_id) else {
9804                continue;
9805            };
9806            let Some(pk) = schema.primary_key() else {
9807                continue;
9808            };
9809            match staged {
9810                Staged::Delete(row_id) => {
9811                    let handle = self.table_by_id(*table_id)?;
9812                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
9813                        continue;
9814                    };
9815                    let Some(pk_value) = row.columns.get(&pk.id) else {
9816                        continue;
9817                    };
9818                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
9819                    delete_by_key
9820                        .entry((*table_id, pk_value.encode_key()))
9821                        .or_default()
9822                        .push_back(idx);
9823                }
9824                Staged::Put(cells) => {
9825                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
9826                        put_by_key
9827                            .entry((*table_id, value.encode_key()))
9828                            .or_default()
9829                            .push_back(idx);
9830                    }
9831                }
9832                Staged::Update { row_id, .. } => {
9833                    let handle = self.table_by_id(*table_id)?;
9834                    let row = handle.lock().get(*row_id, snapshot);
9835                    if let Some(row) = row {
9836                        old_rows.insert(idx, TriggerRowImage::from_row(row));
9837                    }
9838                }
9839                Staged::Truncate => {}
9840            }
9841        }
9842
9843        let mut paired_delete = std::collections::HashSet::new();
9844        let mut paired_put = std::collections::HashSet::new();
9845        let mut events = Vec::new();
9846
9847        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
9848            commit_prepare_checkpoint(control, pair_index)?;
9849            let Some(puts) = put_by_key.get_mut(key) else {
9850                continue;
9851            };
9852            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
9853                paired_delete.insert(delete_idx);
9854                paired_put.insert(put_idx);
9855                let (table_id, _) = &staging[put_idx];
9856                let Some(table_name) = table_names.get(table_id).cloned() else {
9857                    continue;
9858                };
9859                let old = old_rows.get(&delete_idx).cloned();
9860                let new = match &staging[put_idx].1 {
9861                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
9862                    _ => None,
9863                };
9864                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
9865                events.push(WriteEvent {
9866                    table: table_name,
9867                    kind: TriggerEvent::Update,
9868                    old,
9869                    new,
9870                    changed_columns,
9871                    op_indices: vec![delete_idx, put_idx],
9872                    put_idx: Some(put_idx),
9873                    trigger_stack: Self::trigger_stack_for_indices(
9874                        trigger_stacks,
9875                        &[delete_idx, put_idx],
9876                    ),
9877                });
9878            }
9879        }
9880
9881        for (idx, (table_id, staged)) in staging.iter().enumerate() {
9882            commit_prepare_checkpoint(control, idx)?;
9883            let Some(table_name) = table_names.get(table_id).cloned() else {
9884                continue;
9885            };
9886            match staged {
9887                Staged::Put(cells) if !paired_put.contains(&idx) => {
9888                    let new = Some(TriggerRowImage::from_cells(cells));
9889                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
9890                    events.push(WriteEvent {
9891                        table: table_name,
9892                        kind: TriggerEvent::Insert,
9893                        old: None,
9894                        new,
9895                        changed_columns,
9896                        op_indices: vec![idx],
9897                        put_idx: Some(idx),
9898                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
9899                    });
9900                }
9901                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
9902                    let old = match old_rows.get(&idx).cloned() {
9903                        Some(old) => Some(old),
9904                        None => {
9905                            let handle = self.table_by_id(*table_id)?;
9906                            let row = handle.lock().get(*row_id, snapshot);
9907                            row.map(TriggerRowImage::from_row)
9908                        }
9909                    };
9910                    let Some(old) = old else {
9911                        continue;
9912                    };
9913                    let changed_columns = old.columns.keys().copied().collect();
9914                    events.push(WriteEvent {
9915                        table: table_name,
9916                        kind: TriggerEvent::Delete,
9917                        old: Some(old),
9918                        new: None,
9919                        changed_columns,
9920                        op_indices: vec![idx],
9921                        put_idx: None,
9922                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
9923                    });
9924                }
9925                Staged::Update { new_row: cells, .. } => {
9926                    let old = old_rows.get(&idx).cloned();
9927                    let new = Some(TriggerRowImage::from_cells(cells));
9928                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
9929                    events.push(WriteEvent {
9930                        table: table_name,
9931                        kind: TriggerEvent::Update,
9932                        old,
9933                        new,
9934                        changed_columns,
9935                        op_indices: vec![idx],
9936                        put_idx: Some(idx),
9937                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
9938                    });
9939                }
9940                Staged::Truncate => {}
9941                _ => {}
9942            }
9943        }
9944
9945        Ok(events)
9946    }
9947
9948    #[allow(clippy::too_many_arguments)]
9949    fn execute_trigger_program(
9950        &self,
9951        trigger: &StoredTrigger,
9952        event: &WriteEvent,
9953        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
9954        out: &mut TriggerProgramOutput<'_>,
9955        trigger_stack: &[String],
9956        config: &TriggerConfig,
9957        read_epoch: Epoch,
9958        control: Option<&crate::ExecutionControl>,
9959    ) -> Result<TriggerProgramOutcome> {
9960        let mut event = event.clone();
9961        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
9962        self.execute_trigger_steps(
9963            trigger,
9964            &trigger.program.steps,
9965            &mut event,
9966            staging,
9967            out,
9968            trigger_stack,
9969            config,
9970            &mut select_results,
9971            0,
9972            None,
9973            read_epoch,
9974            control,
9975        )
9976    }
9977
9978    #[allow(clippy::too_many_arguments)]
9979    fn execute_trigger_steps(
9980        &self,
9981        trigger: &StoredTrigger,
9982        steps: &[TriggerStep],
9983        event: &mut WriteEvent,
9984        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
9985        out: &mut TriggerProgramOutput<'_>,
9986        trigger_stack: &[String],
9987        config: &TriggerConfig,
9988        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
9989        depth: u32,
9990        selected: Option<&TriggerRowImage>,
9991        read_epoch: Epoch,
9992        control: Option<&crate::ExecutionControl>,
9993    ) -> Result<TriggerProgramOutcome> {
9994        let _ = depth;
9995        for (step_index, step) in steps.iter().enumerate() {
9996            commit_prepare_checkpoint(control, step_index)?;
9997            match step {
9998                TriggerStep::SetNew { cells } => {
9999                    if trigger.timing != TriggerTiming::Before {
10000                        return Err(MongrelError::InvalidArgument(
10001                            "SetNew trigger step is only valid in BEFORE triggers".into(),
10002                        ));
10003                    }
10004                    let put_idx = event.put_idx.ok_or_else(|| {
10005                        MongrelError::InvalidArgument(
10006                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
10007                        )
10008                    })?;
10009                    let staging = staging.as_deref_mut().ok_or_else(|| {
10010                        MongrelError::InvalidArgument(
10011                            "SetNew trigger step requires mutable trigger staging".into(),
10012                        )
10013                    })?;
10014                    let mut update_changed_columns = None;
10015                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
10016                        Some(crate::txn::Staged::Put(cells)) => cells,
10017                        Some(crate::txn::Staged::Update {
10018                            new_row,
10019                            changed_columns,
10020                            ..
10021                        }) => {
10022                            update_changed_columns = Some(changed_columns);
10023                            new_row
10024                        }
10025                        _ => {
10026                            return Err(MongrelError::InvalidArgument(
10027                                "SetNew trigger step target row is not mutable".into(),
10028                            ))
10029                        }
10030                    };
10031                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
10032                        row_cells.retain(|(id, _)| *id != column_id);
10033                        row_cells.push((column_id, value.clone()));
10034                        if let Some(changed_columns) = &mut update_changed_columns {
10035                            changed_columns.push(column_id);
10036                        }
10037                        if let Some(new) = &mut event.new {
10038                            new.columns.insert(column_id, value);
10039                        }
10040                    }
10041                    row_cells.sort_by_key(|(id, _)| *id);
10042                    if let Some(changed_columns) = update_changed_columns {
10043                        changed_columns.sort_unstable();
10044                        changed_columns.dedup();
10045                    }
10046                }
10047                TriggerStep::Insert { table, cells } => {
10048                    let cells = eval_trigger_cells(cells, event, selected)?;
10049                    if let Ok(table_id) = self.table_id(table) {
10050                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
10051                        out.added_stacks.push(trigger_stack.to_vec());
10052                    } else if self.external_table(table).is_some() {
10053                        out.added_external.push(ExternalTriggerWrite::Insert {
10054                            table: table.clone(),
10055                            cells,
10056                        });
10057                    } else {
10058                        return Err(MongrelError::NotFound(format!(
10059                            "trigger {:?} insert target {table:?} not found",
10060                            trigger.name
10061                        )));
10062                    }
10063                }
10064                TriggerStep::UpdateByPk { table, pk, cells } => {
10065                    let pk = eval_trigger_value(pk, event, selected)?;
10066                    let cells = eval_trigger_cells(cells, event, selected)?;
10067                    if self.external_table(table).is_some() {
10068                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
10069                            table: table.clone(),
10070                            pk,
10071                            cells,
10072                        });
10073                    } else {
10074                        let row_id = self
10075                            .table(table)?
10076                            .lock()
10077                            .lookup_pk(&pk.encode_key())
10078                            .ok_or_else(|| {
10079                                MongrelError::NotFound(format!(
10080                                    "trigger {:?} update target not found",
10081                                    trigger.name
10082                                ))
10083                            })?;
10084                        let handle = self.table(table)?;
10085                        let snapshot = Snapshot::at(self.epoch.visible());
10086                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
10087                            MongrelError::NotFound(format!(
10088                                "trigger {:?} update target not visible",
10089                                trigger.name
10090                            ))
10091                        })?;
10092                        let mut changed_columns = cells
10093                            .iter()
10094                            .map(|(column_id, _)| *column_id)
10095                            .collect::<Vec<_>>();
10096                        changed_columns.sort_unstable();
10097                        changed_columns.dedup();
10098                        let mut merged = old.columns;
10099                        for (column_id, value) in cells {
10100                            merged.insert(column_id, value);
10101                        }
10102                        out.added.push((
10103                            self.table_id(table)?,
10104                            crate::txn::Staged::Update {
10105                                row_id,
10106                                new_row: merged.into_iter().collect(),
10107                                changed_columns,
10108                            },
10109                        ));
10110                        out.added_stacks.push(trigger_stack.to_vec());
10111                    }
10112                }
10113                TriggerStep::DeleteByPk { table, pk } => {
10114                    let pk = eval_trigger_value(pk, event, selected)?;
10115                    if self.external_table(table).is_some() {
10116                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
10117                            table: table.clone(),
10118                            pk,
10119                        });
10120                    } else {
10121                        let row_id = self
10122                            .table(table)?
10123                            .lock()
10124                            .lookup_pk(&pk.encode_key())
10125                            .ok_or_else(|| {
10126                                MongrelError::NotFound(format!(
10127                                    "trigger {:?} delete target not found",
10128                                    trigger.name
10129                                ))
10130                            })?;
10131                        out.added
10132                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
10133                        out.added_stacks.push(trigger_stack.to_vec());
10134                    }
10135                }
10136                TriggerStep::Select {
10137                    id,
10138                    table,
10139                    conditions,
10140                } => {
10141                    let schema = self.table(table)?.lock().schema().clone();
10142                    let snapshot = Snapshot::at(read_epoch);
10143                    let handle = self.table(table)?;
10144                    let rows = match control {
10145                        Some(control) => {
10146                            handle.lock().visible_rows_controlled(snapshot, control)?
10147                        }
10148                        None => handle.lock().visible_rows(snapshot)?,
10149                    };
10150                    let mut matched = Vec::new();
10151                    for (row_index, row) in rows.into_iter().enumerate() {
10152                        commit_prepare_checkpoint(control, row_index)?;
10153                        let image = TriggerRowImage::from_row(row);
10154                        let passes = conditions
10155                            .iter()
10156                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
10157                            .collect::<Result<Vec<_>>>()?
10158                            .into_iter()
10159                            .all(|b| b);
10160                        if passes {
10161                            matched.push(image);
10162                        }
10163                    }
10164                    if let Some(pk) = schema.primary_key() {
10165                        matched.sort_by(|a, b| {
10166                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
10167                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
10168                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
10169                        });
10170                    }
10171                    select_results.insert(id.clone(), matched);
10172                }
10173                TriggerStep::Foreach { id, steps } => {
10174                    let rows = select_results.get(id).ok_or_else(|| {
10175                        MongrelError::InvalidArgument(format!(
10176                            "trigger {:?} foreach references unknown select id {id:?}",
10177                            trigger.name
10178                        ))
10179                    })?;
10180                    if rows.len() > config.max_loop_iterations as usize {
10181                        return Err(MongrelError::InvalidArgument(format!(
10182                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
10183                            trigger.name, config.max_loop_iterations
10184                        )));
10185                    }
10186                    for (row_index, row) in rows.clone().into_iter().enumerate() {
10187                        commit_prepare_checkpoint(control, row_index)?;
10188                        let result = self.execute_trigger_steps(
10189                            trigger,
10190                            steps,
10191                            event,
10192                            staging.as_deref_mut(),
10193                            out,
10194                            trigger_stack,
10195                            config,
10196                            select_results,
10197                            depth + 1,
10198                            Some(&row),
10199                            read_epoch,
10200                            control,
10201                        )?;
10202                        if result == TriggerProgramOutcome::Ignore {
10203                            return Ok(TriggerProgramOutcome::Ignore);
10204                        }
10205                    }
10206                }
10207                TriggerStep::DeleteWhere { table, conditions } => {
10208                    let schema = self.table(table)?.lock().schema().clone();
10209                    let snapshot = Snapshot::at(read_epoch);
10210                    let handle = self.table(table)?;
10211                    let rows = match control {
10212                        Some(control) => {
10213                            handle.lock().visible_rows_controlled(snapshot, control)?
10214                        }
10215                        None => handle.lock().visible_rows(snapshot)?,
10216                    };
10217                    let table_id = self.table_id(table)?;
10218                    let mut to_delete = Vec::new();
10219                    for (row_index, row) in rows.into_iter().enumerate() {
10220                        commit_prepare_checkpoint(control, row_index)?;
10221                        let image = TriggerRowImage::from_row(row.clone());
10222                        let passes = conditions
10223                            .iter()
10224                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
10225                            .collect::<Result<Vec<_>>>()?
10226                            .into_iter()
10227                            .all(|b| b);
10228                        if passes {
10229                            to_delete.push((table_id, row.row_id));
10230                        }
10231                    }
10232                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
10233                        commit_prepare_checkpoint(control, row_index)?;
10234                        out.added
10235                            .push((table_id, crate::txn::Staged::Delete(row_id)));
10236                        out.added_stacks.push(trigger_stack.to_vec());
10237                    }
10238                }
10239                TriggerStep::UpdateWhere {
10240                    table,
10241                    conditions,
10242                    cells,
10243                } => {
10244                    let schema = self.table(table)?.lock().schema().clone();
10245                    let snapshot = Snapshot::at(read_epoch);
10246                    let handle = self.table(table)?;
10247                    let rows = match control {
10248                        Some(control) => {
10249                            handle.lock().visible_rows_controlled(snapshot, control)?
10250                        }
10251                        None => handle.lock().visible_rows(snapshot)?,
10252                    };
10253                    let table_id = self.table_id(table)?;
10254                    let mut changed_columns =
10255                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
10256                    changed_columns.sort_unstable();
10257                    changed_columns.dedup();
10258                    let mut to_update = Vec::new();
10259                    for (row_index, row) in rows.into_iter().enumerate() {
10260                        commit_prepare_checkpoint(control, row_index)?;
10261                        let image = TriggerRowImage::from_row(row.clone());
10262                        let passes = conditions
10263                            .iter()
10264                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
10265                            .collect::<Result<Vec<_>>>()?
10266                            .into_iter()
10267                            .all(|b| b);
10268                        if passes {
10269                            let new_cells = cells
10270                                .iter()
10271                                .map(|cell| {
10272                                    Ok((
10273                                        cell.column_id,
10274                                        eval_trigger_value(&cell.value, event, Some(&image))?,
10275                                    ))
10276                                })
10277                                .collect::<Result<Vec<_>>>()?;
10278                            let mut merged = row.columns.clone();
10279                            for (column_id, value) in new_cells {
10280                                merged.insert(column_id, value);
10281                            }
10282                            to_update.push((table_id, row.row_id, merged));
10283                        }
10284                    }
10285                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
10286                    {
10287                        commit_prepare_checkpoint(control, row_index)?;
10288                        out.added.push((
10289                            table_id,
10290                            crate::txn::Staged::Update {
10291                                row_id,
10292                                new_row: merged.into_iter().collect(),
10293                                changed_columns: changed_columns.clone(),
10294                            },
10295                        ));
10296                        out.added_stacks.push(trigger_stack.to_vec());
10297                    }
10298                }
10299                TriggerStep::Raise { action, message } => match action {
10300                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
10301                    TriggerRaiseAction::Abort
10302                    | TriggerRaiseAction::Fail
10303                    | TriggerRaiseAction::Rollback => {
10304                        let message = eval_trigger_value(message, event, selected)?;
10305                        return Err(MongrelError::TriggerValidation(format!(
10306                            "trigger {:?} raised: {}; trigger stack: {}",
10307                            trigger.name,
10308                            trigger_message(message),
10309                            Self::format_trigger_stack(trigger_stack)
10310                        )));
10311                    }
10312                },
10313            }
10314        }
10315        Ok(TriggerProgramOutcome::Continue)
10316    }
10317
10318    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
10319        let Some(stacks) = stacks else {
10320            return Vec::new();
10321        };
10322        let mut out = Vec::new();
10323        for idx in indices {
10324            let Some(stack) = stacks.get(*idx) else {
10325                continue;
10326            };
10327            for name in stack {
10328                if !out.iter().any(|existing| existing == name) {
10329                    out.push(name.clone());
10330                }
10331            }
10332        }
10333        out
10334    }
10335
10336    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
10337        let mut out = stack.to_vec();
10338        out.push(trigger_name.to_string());
10339        out
10340    }
10341
10342    fn format_trigger_stack(stack: &[String]) -> String {
10343        if stack.is_empty() {
10344            "<root>".into()
10345        } else {
10346            stack.join(" -> ")
10347        }
10348    }
10349
10350    /// Authoritatively validate every declared constraint on the staged write
10351    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
10352    /// SET NULL actions into explicit child ops. Called from
10353    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
10354    /// violation as an `Err`, aborting the commit atomically. This is the
10355    /// server-side authority point: concurrent remote writers that each pass
10356    /// their own client-side checks still cannot both commit a violating batch.
10357    ///
10358    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
10359    /// intra-transaction dedup; concurrent-txn races are additionally caught by
10360    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
10361    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
10362    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
10363    /// RESTRICT-only (cascade-truncate is unsupported).
10364    /// S1B-003: acquire Exclusive key claims for every primary key and declared
10365    /// UNIQUE key a transaction's staged puts/updates insert, in ascending key
10366    /// order (ordered acquisition cannot cycle on its own). A concurrent
10367    /// transaction claiming the same key blocks until this one ends, turning
10368    /// the optimistic write-write conflict into a serialization point. Claims
10369    /// release with the transaction's [`TxnLockGuard`].
10370    fn acquire_unique_key_claims(
10371        &self,
10372        txn_id: u64,
10373        staging: &[(u64, crate::txn::Staged)],
10374        control: Option<&crate::ExecutionControl>,
10375    ) -> Result<()> {
10376        let catalog = self.catalog.read();
10377        let has_uniques = staging.iter().any(|(table_id, staged)| {
10378            matches!(
10379                staged,
10380                crate::txn::Staged::Put(_) | crate::txn::Staged::Update { .. }
10381            ) && catalog.tables.iter().any(|entry| {
10382                entry.table_id == *table_id
10383                    && (entry.schema.primary_key().is_some()
10384                        || !entry.schema.constraints.uniques.is_empty())
10385            })
10386        });
10387        if !has_uniques {
10388            return Ok(());
10389        }
10390        let mut claims: Vec<(u64, Vec<u8>)> = Vec::new();
10391        for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
10392            commit_prepare_checkpoint(control, staged_index)?;
10393            let cells = match staged {
10394                crate::txn::Staged::Put(cells) => cells,
10395                crate::txn::Staged::Update { new_row, .. } => new_row,
10396                _ => continue,
10397            };
10398            let Some(entry) = catalog
10399                .tables
10400                .iter()
10401                .find(|entry| entry.table_id == *table_id)
10402            else {
10403                continue;
10404            };
10405            for column in &entry.schema.columns {
10406                if !column
10407                    .flags
10408                    .contains(crate::schema::ColumnFlags::PRIMARY_KEY)
10409                {
10410                    continue;
10411                }
10412                if let Some((_, value)) = cells.iter().find(|(id, _)| *id == column.id) {
10413                    let mut key = b"pk:".to_vec();
10414                    key.extend_from_slice(&value.encode_key());
10415                    claims.push((*table_id, key));
10416                }
10417            }
10418            // Declared non-PK unique constraints claim their own namespace
10419            // (folding the constraint id into the key, per LockKey::Key's
10420            // multi-key-space rule). NULL components skip the constraint —
10421            // and the claim — per SQL semantics.
10422            let cells_map: HashMap<u16, Value> = cells.iter().cloned().collect();
10423            for uc in &entry.schema.constraints.uniques {
10424                if let Some(composite) =
10425                    crate::constraint::encode_composite_key(&uc.columns, &cells_map)
10426                {
10427                    let mut key = format!("uq{}:", uc.id).into_bytes();
10428                    key.extend_from_slice(&composite);
10429                    claims.push((*table_id, key));
10430                }
10431            }
10432        }
10433        claims.sort();
10434        claims.dedup();
10435        for (table_id, key) in claims {
10436            self.acquire_txn_lock(
10437                txn_id,
10438                crate::locks::LockKey::key(table_id, key),
10439                crate::locks::LockMode::Exclusive,
10440                control,
10441            )?;
10442        }
10443        Ok(())
10444    }
10445
10446    /// S1B-003: one FK parent-protection acquisition. `Err` propagates the
10447    /// deadlock/deadline/cancellation outcome; the test seam fires after each
10448    /// successful acquisition.
10449    fn acquire_fk_lock(
10450        &self,
10451        txn_id: u64,
10452        table_id: u64,
10453        key: &[u8],
10454        mode: crate::locks::LockMode,
10455        control: Option<&crate::ExecutionControl>,
10456    ) -> Result<()> {
10457        let mut namespaced = b"fk:".to_vec();
10458        namespaced.extend_from_slice(key);
10459        self.acquire_txn_lock(
10460            txn_id,
10461            crate::locks::LockKey::key(table_id, namespaced),
10462            mode,
10463            control,
10464        )?;
10465        // The hook is cloned out before firing: holding the slot's mutex while
10466        // a parked hook waits would block every other commit's hook call.
10467        let hook = self.fk_lock_hook.lock().clone();
10468        if let Some(hook) = hook {
10469            hook();
10470        }
10471        Ok(())
10472    }
10473
10474    fn validate_constraints(
10475        &self,
10476        txn_id: u64,
10477        staging: &mut Vec<(u64, crate::txn::Staged)>,
10478        read_epoch: Epoch,
10479        control: Option<&crate::ExecutionControl>,
10480    ) -> Result<()> {
10481        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
10482        use crate::memtable::Row;
10483        use crate::txn::Staged;
10484        use std::collections::HashSet;
10485
10486        commit_prepare_checkpoint(control, 0)?;
10487        let snapshot = Snapshot::at(read_epoch);
10488        let cat = self.catalog.read();
10489
10490        // Collect live (id, name, constraints-bearing?) for staged tables.
10491        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
10492            .tables
10493            .iter()
10494            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
10495            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
10496            .collect();
10497
10498        // Fast path: bail if no live table declares any constraints at all.
10499        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
10500        if !any_constraints {
10501            return Ok(());
10502        }
10503
10504        // Lazily-loaded visible rows per table, shared across checks.
10505        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
10506        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
10507            if let Some(r) = rows_cache.get(&table_id) {
10508                return Ok(r.clone());
10509            }
10510            let handle = self.table_by_id(table_id)?;
10511            let rows = match control {
10512                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
10513                None => handle.lock().visible_rows(snapshot)?,
10514            };
10515            rows_cache.insert(table_id, rows.clone());
10516            Ok(rows)
10517        };
10518
10519        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
10520        // carry an explicit old RowId + full new image. This makes action choice
10521        // reliable even when the referenced key itself changes; a delete+put
10522        // heuristic cannot distinguish that from unrelated operations.
10523        let mut processed_updates = HashSet::new();
10524        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
10525        let mut update_pass = 0_usize;
10526        loop {
10527            commit_prepare_checkpoint(control, update_pass)?;
10528            update_pass += 1;
10529            let updates: Vec<PendingUpdate> = staging
10530                .iter()
10531                .enumerate()
10532                .filter_map(|(index, (table_id, op))| match op {
10533                    Staged::Update {
10534                        row_id,
10535                        new_row: cells,
10536                        ..
10537                    } if !processed_updates.contains(&index) => {
10538                        Some((index, *table_id, *row_id, cells.clone()))
10539                    }
10540                    _ => None,
10541                })
10542                .collect();
10543            if updates.is_empty() {
10544                break;
10545            }
10546            let mut new_ops = Vec::new();
10547            for (update_index, (index, table_id, row_id, new_cells)) in
10548                updates.into_iter().enumerate()
10549            {
10550                commit_prepare_checkpoint(control, update_index)?;
10551                processed_updates.insert(index);
10552                let Some(tname) = live
10553                    .iter()
10554                    .find(|(id, _, _)| *id == table_id)
10555                    .map(|(_, name, _)| *name)
10556                else {
10557                    continue;
10558                };
10559                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
10560                    continue;
10561                };
10562                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
10563                for (child_id, _child_name, child_schema) in &live {
10564                    for fk in &child_schema.constraints.foreign_keys {
10565                        if fk.ref_table != tname {
10566                            continue;
10567                        }
10568                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
10569                        else {
10570                            continue;
10571                        };
10572                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
10573                            == Some(old_key.as_slice())
10574                        {
10575                            continue;
10576                        }
10577                        if fk.on_update == FkAction::Restrict {
10578                            continue;
10579                        }
10580                        // S1B-003: the referenced key is being changed, so this
10581                        // update removes it for any action — hold an Exclusive
10582                        // parent-protection lock against concurrent child
10583                        // inserts referencing the old key.
10584                        self.acquire_fk_lock(
10585                            txn_id,
10586                            table_id,
10587                            &old_key,
10588                            crate::locks::LockMode::Exclusive,
10589                            control,
10590                        )?;
10591                        let child_rows = load_rows(*child_id)?;
10592                        for (child_index, child) in child_rows.into_iter().enumerate() {
10593                            commit_prepare_checkpoint(control, child_index)?;
10594                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
10595                                != Some(old_key.as_slice())
10596                            {
10597                                continue;
10598                            }
10599                            if staging.iter().any(|(id, op)| {
10600                                *id == *child_id
10601                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
10602                            }) {
10603                                continue;
10604                            }
10605                            let mut cells: Vec<(u16, Value)> = child
10606                                .columns
10607                                .iter()
10608                                .map(|(column_id, value)| (*column_id, value.clone()))
10609                                .collect();
10610                            for (child_column, parent_column) in
10611                                fk.columns.iter().zip(&fk.ref_columns)
10612                            {
10613                                cells.retain(|(column_id, _)| column_id != child_column);
10614                                let value = match fk.on_update {
10615                                    FkAction::Cascade => {
10616                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
10617                                    }
10618                                    FkAction::SetNull => Value::Null,
10619                                    FkAction::Restrict => {
10620                                        return Err(MongrelError::Other(
10621                                            "restricted foreign-key update reached cascade preparation"
10622                                                .into(),
10623                                        ));
10624                                    }
10625                                };
10626                                cells.push((*child_column, value));
10627                            }
10628                            cells.sort_by_key(|(column_id, _)| *column_id);
10629                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
10630                                *id == *child_id
10631                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
10632                            }) {
10633                                if let Staged::Update {
10634                                    new_row: existing,
10635                                    changed_columns,
10636                                    ..
10637                                } = &mut staging[existing_index].1 {
10638                                    changed_columns.extend(fk.columns.iter().copied());
10639                                    changed_columns.sort_unstable();
10640                                    changed_columns.dedup();
10641                                    if *existing != cells {
10642                                        *existing = cells;
10643                                        processed_updates.remove(&existing_index);
10644                                    }
10645                                }
10646                            } else {
10647                                new_ops.push((
10648                                    *child_id,
10649                                    Staged::Update {
10650                                        row_id: child.row_id,
10651                                        new_row: cells,
10652                                        changed_columns: fk.columns.clone(),
10653                                    },
10654                                ));
10655                            }
10656                        }
10657                    }
10658                }
10659            }
10660            staging.extend(new_ops);
10661        }
10662
10663        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
10664        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
10665        // enforced as a violation in Phase B. `cascaded` records every delete
10666        // we have already expanded so a self-referential CASCADE FK cannot loop.
10667        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
10668        let mut cascade_pass = 0_usize;
10669        loop {
10670            commit_prepare_checkpoint(control, cascade_pass)?;
10671            cascade_pass += 1;
10672            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
10673            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
10674                .iter()
10675                .filter_map(|(t, op)| match op {
10676                    Staged::Delete(rid) => Some((*t, *rid)),
10677                    _ => None,
10678                })
10679                .collect();
10680            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
10681                commit_prepare_checkpoint(control, delete_index)?;
10682                if !cascaded.insert((table_id, rid.0)) {
10683                    continue;
10684                }
10685                let Some(tname) = live
10686                    .iter()
10687                    .find(|(t, _, _)| *t == table_id)
10688                    .map(|(_, n, _)| *n)
10689                else {
10690                    continue;
10691                };
10692                let parent_handle = self.table_by_id(table_id)?;
10693                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
10694                    continue;
10695                };
10696                for (child_id, _child_name, child_schema) in &live {
10697                    for fk in &child_schema.constraints.foreign_keys {
10698                        if fk.ref_table != tname {
10699                            continue;
10700                        }
10701                        let Some(parent_key) =
10702                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
10703                        else {
10704                            continue;
10705                        };
10706                        // Suppress ON DELETE cascade/set-null when this "delete"
10707                        // is actually half of an UPDATE encoded as Delete(old)+
10708                        // Put(new): if a staged Put in the SAME table still
10709                        // provides the referenced parent key, the parent still
10710                        // exists (its non-key columns changed) and the children
10711                        // must be left alone. A genuine delete, or an update
10712                        // that CHANGES the referenced key, has no preserving Put
10713                        // → cascade fires as before.
10714                        let key_preserved = staging.iter().any(|(t, op)| {
10715                            if *t != table_id {
10716                                return false;
10717                            }
10718                            let Staged::Put(cells) = op else {
10719                                return false;
10720                            };
10721                            let map: HashMap<u16, crate::memtable::Value> =
10722                                cells.iter().cloned().collect();
10723                            encode_composite_key(&fk.ref_columns, &map).as_deref()
10724                                == Some(parent_key.as_slice())
10725                        });
10726                        if key_preserved {
10727                            continue;
10728                        }
10729                        // S1B-003: the referenced parent key is genuinely being
10730                        // removed (delete, or the delete half of a key-changing
10731                        // update), for every FK action — hold an Exclusive
10732                        // parent-protection lock against concurrent child
10733                        // inserts referencing it. RESTRICT fks are enforced in
10734                        // Phase B; the claim covers them too.
10735                        self.acquire_fk_lock(
10736                            txn_id,
10737                            table_id,
10738                            &parent_key,
10739                            crate::locks::LockMode::Exclusive,
10740                            control,
10741                        )?;
10742                        match fk.on_delete {
10743                            FkAction::Restrict => continue,
10744                            FkAction::Cascade => {
10745                                let child_rows = load_rows(*child_id)?;
10746                                for (child_index, cr) in child_rows.iter().enumerate() {
10747                                    commit_prepare_checkpoint(control, child_index)?;
10748                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
10749                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
10750                                            == Some(parent_key.as_slice())
10751                                    {
10752                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
10753                                    }
10754                                }
10755                            }
10756                            FkAction::SetNull => {
10757                                let child_rows = load_rows(*child_id)?;
10758                                for (child_index, cr) in child_rows.iter().enumerate() {
10759                                    commit_prepare_checkpoint(control, child_index)?;
10760                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
10761                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
10762                                            == Some(parent_key.as_slice())
10763                                    {
10764                                        // Re-emit the child row with the FK
10765                                        // columns set to NULL (delete + put).
10766                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
10767                                            .columns
10768                                            .iter()
10769                                            .map(|(k, v)| (*k, v.clone()))
10770                                            .collect();
10771                                        for cid in &fk.columns {
10772                                            cells.retain(|(k, _)| k != cid);
10773                                            cells.push((*cid, crate::memtable::Value::Null));
10774                                        }
10775                                        new_ops.push((
10776                                            *child_id,
10777                                            Staged::Update {
10778                                                row_id: cr.row_id,
10779                                                new_row: cells,
10780                                                changed_columns: fk.columns.clone(),
10781                                            },
10782                                        ));
10783                                    }
10784                                }
10785                            }
10786                        }
10787                    }
10788                }
10789            }
10790            if new_ops.is_empty() {
10791                break;
10792            }
10793            staging.extend(new_ops);
10794        }
10795
10796        // Rows staged for deletion in THIS transaction (now including cascaded
10797        // deletes). Used to exclude the old version of an updated row from
10798        // unique-existence scans.
10799        let staged_deletes: HashSet<(u64, u64)> = staging
10800            .iter()
10801            .filter_map(|(t, op)| match op {
10802                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
10803                _ => None,
10804            })
10805            .collect();
10806
10807        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
10808        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
10809
10810        // ── Phase B: validate the fully-expanded staging set.
10811        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
10812            commit_prepare_checkpoint(control, operation_index)?;
10813            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
10814            else {
10815                continue;
10816            };
10817            let cells_map: HashMap<u16, crate::memtable::Value>;
10818            match op {
10819                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
10820                    cells_map = cells.iter().cloned().collect();
10821
10822                    // CHECK constraints.
10823                    if !schema.constraints.checks.is_empty() {
10824                        validate_checks(&schema.constraints.checks, &cells_map)?;
10825                    }
10826
10827                    // UNIQUE (non-PK) constraints.
10828                    for uc in &schema.constraints.uniques {
10829                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
10830                            continue; // NULL in a constrained column → skip (SQL).
10831                        };
10832                        let marker = (*table_id, uc.id, key.clone());
10833                        if !seen_unique.insert(marker) {
10834                            return Err(MongrelError::Conflict(format!(
10835                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
10836                                uc.name
10837                            )));
10838                        }
10839                        let rows = load_rows(*table_id)?;
10840                        for (row_index, r) in rows.iter().enumerate() {
10841                            commit_prepare_checkpoint(control, row_index)?;
10842                            // Skip rows this same transaction is deleting (the
10843                            // old version of an updated/cascade-deleted row).
10844                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
10845                                continue;
10846                            }
10847                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
10848                                if theirs == key {
10849                                    return Err(MongrelError::Conflict(format!(
10850                                        "UNIQUE constraint '{}' on table '{tname}' violated",
10851                                        uc.name
10852                                    )));
10853                                }
10854                            }
10855                        }
10856                    }
10857
10858                    // FK insert-side: parent must exist.
10859                    for fk in &schema.constraints.foreign_keys {
10860                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
10861                            continue; // NULL FK component → not checked (SQL).
10862                        };
10863                        let Some(parent_id) = cat
10864                            .tables
10865                            .iter()
10866                            .find(|t| t.name == fk.ref_table)
10867                            .map(|t| t.table_id)
10868                        else {
10869                            return Err(MongrelError::InvalidArgument(format!(
10870                                "FOREIGN KEY '{}' references unknown table '{}'",
10871                                fk.name, fk.ref_table
10872                            )));
10873                        };
10874                        // S1B-003: hold a Shared parent-protection lock on the
10875                        // referenced key while checking existence, so a
10876                        // concurrent parent delete or key-changing update
10877                        // serializes against this insert.
10878                        self.acquire_fk_lock(
10879                            txn_id,
10880                            parent_id,
10881                            &child_key,
10882                            crate::locks::LockMode::Shared,
10883                            control,
10884                        )?;
10885                        let parent_rows = load_rows(parent_id)?;
10886                        let mut found = false;
10887                        for (row_index, r) in parent_rows.iter().enumerate() {
10888                            commit_prepare_checkpoint(control, row_index)?;
10889                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
10890                                continue;
10891                            }
10892                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
10893                                if pkey == child_key {
10894                                    found = true;
10895                                    break;
10896                                }
10897                            }
10898                        }
10899                        // Final-write-set FK validation: a parent inserted in
10900                        // THIS transaction also satisfies the FK. This enables
10901                        // atomic parent+child batches and cyclical/mutual FK
10902                        // inserts within a single transaction — the child sees
10903                        // the staged parent put even though it is not committed
10904                        // yet.
10905                        if !found {
10906                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
10907                                commit_prepare_checkpoint(control, staged_index)?;
10908                                if *st_table != parent_id {
10909                                    continue;
10910                                }
10911                                if let Staged::Put(pcells)
10912                                | Staged::Update {
10913                                    new_row: pcells, ..
10914                                } = st_op
10915                                {
10916                                    let pmap: HashMap<u16, crate::memtable::Value> =
10917                                        pcells.iter().cloned().collect();
10918                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
10919                                    {
10920                                        if pkey == child_key {
10921                                            found = true;
10922                                            break;
10923                                        }
10924                                    }
10925                                }
10926                            }
10927                        }
10928                        if !found {
10929                            return Err(MongrelError::Conflict(format!(
10930                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
10931                                fk.name, fk.ref_table
10932                            )));
10933                        }
10934                    }
10935
10936                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
10937                    // expanded in Phase A; here the final child write set is
10938                    // known, so a child explicitly moved/deleted by this same
10939                    // transaction does not cause a false violation.
10940                    if let Staged::Update { row_id, .. } = op {
10941                        let parent_handle = self.table_by_id(*table_id)?;
10942                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
10943                            continue;
10944                        };
10945                        for (child_id, child_name, child_schema) in &live {
10946                            for fk in &child_schema.constraints.foreign_keys {
10947                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
10948                                    continue;
10949                                }
10950                                let Some(old_key) =
10951                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
10952                                else {
10953                                    continue;
10954                                };
10955                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
10956                                    == Some(old_key.as_slice())
10957                                {
10958                                    continue;
10959                                }
10960                                // S1B-003: the referenced key is being changed —
10961                                // hold an Exclusive parent-protection lock
10962                                // against concurrent child inserts referencing
10963                                // the old key.
10964                                self.acquire_fk_lock(
10965                                    txn_id,
10966                                    *table_id,
10967                                    &old_key,
10968                                    crate::locks::LockMode::Exclusive,
10969                                    control,
10970                                )?;
10971                                for (child_index, child) in
10972                                    load_rows(*child_id)?.into_iter().enumerate()
10973                                {
10974                                    commit_prepare_checkpoint(control, child_index)?;
10975                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
10976                                        != Some(old_key.as_slice())
10977                                    {
10978                                        continue;
10979                                    }
10980                                    let replacement = staging.iter().find_map(|(id, op)| {
10981                                        if *id != *child_id {
10982                                            return None;
10983                                        }
10984                                        match op {
10985                                            Staged::Delete(id) if *id == child.row_id => Some(None),
10986                                            Staged::Update {
10987                                                row_id,
10988                                                new_row: cells,
10989                                                ..
10990                                            } if *row_id == child.row_id => {
10991                                                let map: HashMap<u16, Value> =
10992                                                    cells.iter().cloned().collect();
10993                                                Some(encode_composite_key(&fk.columns, &map))
10994                                            }
10995                                            _ => None,
10996                                        }
10997                                    });
10998                                    if replacement.is_some_and(|key| {
10999                                        key.as_deref() != Some(old_key.as_slice())
11000                                    }) {
11001                                        continue;
11002                                    }
11003                                    return Err(MongrelError::Conflict(format!(
11004                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
11005                                        fk.name
11006                                    )));
11007                                }
11008                            }
11009                        }
11010                    }
11011                }
11012                Staged::Delete(rid) => {
11013                    // FK ON DELETE RESTRICT: a child row (whose FK action is
11014                    // RESTRICT) referencing this parent blocks the delete.
11015                    // CASCADE/SET NULL children were expanded in Phase A.
11016                    let parent_handle = self.table_by_id(*table_id)?;
11017                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
11018                        continue;
11019                    };
11020                    for (child_id, child_name, child_schema) in &live {
11021                        for fk in &child_schema.constraints.foreign_keys {
11022                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
11023                                continue;
11024                            }
11025                            let Some(parent_key) =
11026                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
11027                            else {
11028                                continue;
11029                            };
11030                            let child_rows = load_rows(*child_id)?;
11031                            for (row_index, r) in child_rows.iter().enumerate() {
11032                                commit_prepare_checkpoint(control, row_index)?;
11033                                // A child already being deleted by this txn
11034                                // (cascade/inline) is not a restrict violation.
11035                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
11036                                    continue;
11037                                }
11038                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
11039                                    if ck == parent_key {
11040                                        return Err(MongrelError::Conflict(format!(
11041                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
11042                                            fk.name
11043                                        )));
11044                                    }
11045                                }
11046                            }
11047                        }
11048                    }
11049                }
11050                Staged::Truncate => {
11051                    // Truncate is RESTRICT-only: reject if any child references
11052                    // this table (any FK action), since cascade-truncate is
11053                    // unsupported.
11054                    for (child_id, child_name, child_schema) in &live {
11055                        for fk in &child_schema.constraints.foreign_keys {
11056                            if fk.ref_table != tname {
11057                                continue;
11058                            }
11059                            let child_rows = load_rows(*child_id)?;
11060                            if child_rows
11061                                .iter()
11062                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
11063                            {
11064                                return Err(MongrelError::Conflict(format!(
11065                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
11066                                    fk.name
11067                                )));
11068                            }
11069                        }
11070                    }
11071                }
11072            }
11073        }
11074        Ok(())
11075    }
11076
11077    fn validate_write_permissions(
11078        &self,
11079        staging: &[(u64, crate::txn::Staged)],
11080        principal: Option<&crate::auth::Principal>,
11081        control: Option<&crate::ExecutionControl>,
11082    ) -> Result<()> {
11083        commit_prepare_checkpoint(control, 0)?;
11084        if principal.is_none() && !self.auth_state.require_auth() {
11085            return Ok(());
11086        }
11087        let principal = principal.ok_or(MongrelError::AuthRequired)?;
11088        let needs = summarize_write_permissions(staging);
11089        let catalog = self.catalog.read();
11090
11091        if needs.values().any(|need| need.truncate) {
11092            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
11093        }
11094        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
11095            commit_prepare_checkpoint(control, need_index)?;
11096            let entry = catalog
11097                .tables
11098                .iter()
11099                .find(|entry| {
11100                    entry.table_id == table_id
11101                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
11102                })
11103                .ok_or_else(|| {
11104                    MongrelError::NotFound(format!(
11105                        "live table {table_id} not found during write validation"
11106                    ))
11107                })?;
11108            if matches!(entry.state, TableState::Building { .. }) {
11109                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
11110                continue;
11111            }
11112            if need.insert {
11113                Self::require_columns_for_principal(
11114                    &entry.name,
11115                    &entry.schema,
11116                    crate::auth::ColumnOperation::Insert,
11117                    &need.insert_columns,
11118                    principal,
11119                )?;
11120            }
11121            if need.update {
11122                Self::require_columns_for_principal(
11123                    &entry.name,
11124                    &entry.schema,
11125                    crate::auth::ColumnOperation::Update,
11126                    &need.update_columns,
11127                    principal,
11128                )?;
11129            }
11130            if need.delete {
11131                self.require_for(
11132                    Some(principal),
11133                    &crate::auth::Permission::Delete {
11134                        table: entry.name.clone(),
11135                    },
11136                )?;
11137            }
11138        }
11139        Ok(())
11140    }
11141
11142    fn validate_security_writes(
11143        &self,
11144        staging: &[(u64, crate::txn::Staged)],
11145        read_epoch: Epoch,
11146        explicit_principal: Option<&crate::auth::Principal>,
11147        control: Option<&crate::ExecutionControl>,
11148    ) -> Result<()> {
11149        commit_prepare_checkpoint(control, 0)?;
11150        use crate::security::PolicyCommand;
11151        use crate::txn::Staged;
11152
11153        let catalog = self.catalog.read();
11154        if catalog.security.rls_tables.is_empty() {
11155            return Ok(());
11156        }
11157        let security = catalog.security.clone();
11158        let table_names = catalog
11159            .tables
11160            .iter()
11161            .filter(|entry| matches!(entry.state, TableState::Live))
11162            .map(|entry| (entry.table_id, entry.name.clone()))
11163            .collect::<HashMap<_, _>>();
11164        drop(catalog);
11165        if !staging.iter().any(|(table_id, _)| {
11166            table_names
11167                .get(table_id)
11168                .is_some_and(|table| security.rls_enabled(table))
11169        }) {
11170            return Ok(());
11171        }
11172        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
11173
11174        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
11175            commit_prepare_checkpoint(control, operation_index)?;
11176            let Some(table) = table_names.get(table_id) else {
11177                continue;
11178            };
11179            if !security.rls_enabled(table) || principal.is_admin {
11180                continue;
11181            }
11182            let denied = |command| MongrelError::PermissionDenied {
11183                required: match command {
11184                    PolicyCommand::Insert => crate::auth::Permission::Insert {
11185                        table: table.clone(),
11186                    },
11187                    PolicyCommand::Update => crate::auth::Permission::Update {
11188                        table: table.clone(),
11189                    },
11190                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
11191                        crate::auth::Permission::Delete {
11192                            table: table.clone(),
11193                        }
11194                    }
11195                },
11196                principal: principal.username.clone(),
11197            };
11198            match operation {
11199                Staged::Put(cells) => {
11200                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
11201                    row.columns.extend(cells.iter().cloned());
11202                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
11203                        return Err(denied(PolicyCommand::Insert));
11204                    }
11205                }
11206                Staged::Update {
11207                    row_id,
11208                    new_row: cells,
11209                    ..
11210                } => {
11211                    let old = self
11212                        .table_by_id(*table_id)?
11213                        .lock()
11214                        .get(*row_id, Snapshot::at(read_epoch))
11215                        .ok_or_else(|| {
11216                            MongrelError::NotFound(format!("row {} not found", row_id.0))
11217                        })?;
11218                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
11219                        return Err(denied(PolicyCommand::Update));
11220                    }
11221                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
11222                    new.columns.extend(cells.iter().cloned());
11223                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
11224                        return Err(denied(PolicyCommand::Update));
11225                    }
11226                }
11227                Staged::Delete(row_id) => {
11228                    let old = self
11229                        .table_by_id(*table_id)?
11230                        .lock()
11231                        .get(*row_id, Snapshot::at(read_epoch))
11232                        .ok_or_else(|| {
11233                            MongrelError::NotFound(format!("row {} not found", row_id.0))
11234                        })?;
11235                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
11236                        return Err(denied(PolicyCommand::Delete));
11237                    }
11238                }
11239                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
11240            }
11241        }
11242        Ok(())
11243    }
11244
11245    /// Seal a transaction (spec §9.3):
11246    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
11247    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
11248    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
11249    ///    group-sync, record conflict keys.
11250    /// 3. Publish — apply to tables, advance visible in-order.
11251    #[allow(clippy::too_many_arguments)]
11252    pub(crate) fn commit_transaction_with_external_states(
11253        &self,
11254        txn_id: u64,
11255        read_epoch: Epoch,
11256        staging: Vec<(u64, crate::txn::Staged)>,
11257        external_states: Vec<(String, Vec<u8>)>,
11258        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
11259        security_principal: Option<crate::auth::Principal>,
11260        principal_catalog_bound: bool,
11261        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
11262        context: crate::txn::TxnCommitContext,
11263    ) -> Result<(Epoch, Vec<RowId>)> {
11264        self.commit_transaction_with_external_states_inner(
11265            txn_id,
11266            read_epoch,
11267            staging,
11268            external_states,
11269            materialized_view_updates,
11270            security_principal,
11271            principal_catalog_bound,
11272            external_trigger_bridge,
11273            context,
11274            None,
11275            None,
11276        )
11277    }
11278
11279    #[allow(clippy::too_many_arguments)]
11280    pub(crate) fn commit_transaction_with_external_states_controlled(
11281        &self,
11282        txn_id: u64,
11283        read_epoch: Epoch,
11284        staging: Vec<(u64, crate::txn::Staged)>,
11285        external_states: Vec<(String, Vec<u8>)>,
11286        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
11287        security_principal: Option<crate::auth::Principal>,
11288        principal_catalog_bound: bool,
11289        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
11290        context: crate::txn::TxnCommitContext,
11291        control: &crate::ExecutionControl,
11292        before_commit: &mut dyn FnMut() -> Result<()>,
11293    ) -> Result<(Epoch, Vec<RowId>)> {
11294        self.commit_transaction_with_external_states_inner(
11295            txn_id,
11296            read_epoch,
11297            staging,
11298            external_states,
11299            materialized_view_updates,
11300            security_principal,
11301            principal_catalog_bound,
11302            external_trigger_bridge,
11303            context,
11304            Some(control),
11305            Some(before_commit),
11306        )
11307    }
11308
11309    #[allow(clippy::too_many_arguments)]
11310    fn commit_transaction_with_external_states_inner(
11311        &self,
11312        txn_id: u64,
11313        read_epoch: Epoch,
11314        mut staging: Vec<(u64, crate::txn::Staged)>,
11315        external_states: Vec<(String, Vec<u8>)>,
11316        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
11317        mut security_principal: Option<crate::auth::Principal>,
11318        principal_catalog_bound: bool,
11319        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
11320        context: crate::txn::TxnCommitContext,
11321        control: Option<&crate::ExecutionControl>,
11322        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11323    ) -> Result<(Epoch, Vec<RowId>)> {
11324        use crate::memtable::Row;
11325        use crate::txn::{Staged, StagedOp, WriteKey};
11326        use crate::wal::Op;
11327        use std::collections::hash_map::DefaultHasher;
11328        use std::hash::{Hash, Hasher};
11329        use std::sync::atomic::Ordering;
11330
11331        if txn_id == crate::wal::SYSTEM_TXN_ID {
11332            return Err(MongrelError::Full(
11333                "per-open transaction id namespace exhausted; reopen the database".into(),
11334            ));
11335        }
11336        if self.read_only {
11337            return Err(MongrelError::ReadOnlyReplica);
11338        }
11339        commit_prepare_checkpoint(control, 0)?;
11340        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
11341        self.refresh_security_catalog_if_stale(observed_security_version)?;
11342        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
11343        if self.auth_state.require_auth() && security_principal.is_none() {
11344            return Err(MongrelError::AuthRequired);
11345        }
11346        {
11347            let catalog = self.catalog.read();
11348            if catalog.require_auth
11349                || principal_catalog_bound
11350                || security_principal
11351                    .as_ref()
11352                    .is_some_and(|principal| principal.user_id != 0)
11353            {
11354                let principal = security_principal
11355                    .as_ref()
11356                    .ok_or(MongrelError::AuthRequired)?;
11357                security_principal =
11358                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
11359                if security_principal.is_none() {
11360                    return Err(MongrelError::AuthRequired);
11361                }
11362            }
11363        }
11364        let _replication_guard = self.replication_barrier.read();
11365        if self.poisoned.load(Ordering::Relaxed) {
11366            return Err(MongrelError::Other(
11367                "database poisoned by fsync error".into(),
11368            ));
11369        }
11370        // S1A-004: admit the commit as one core operation (rejects once the
11371        // core is draining, closing, closed, or lifecycle-poisoned; the legacy
11372        // fsync-poison error above still wins for a WAL-poisoned core).
11373        let _operation = self.admit_operation()?;
11374
11375        // ── S1B-003: user transactions hold the schema barrier Shared for the
11376        // whole commit so DDL (Exclusive) excludes concurrent DML. Internal
11377        // commits (catalog backfills, external-table state — `context.state`
11378        // is `None`) skip it: they run under their DDL operation's own
11379        // Exclusive hold, and acquiring a second, differently-keyed hold here
11380        // would self-deadlock. The guard releases every lock this commit
11381        // acquired — schema barrier, unique claims, sequence barriers, FK
11382        // holds — on every exit path (S1B-004 step 12).
11383        if context.state.is_some() {
11384            self.acquire_txn_lock(
11385                txn_id,
11386                crate::locks::LockKey::schema_barrier(),
11387                crate::locks::LockMode::Shared,
11388                control,
11389            )?;
11390        }
11391        let _txn_lock_guard = TxnLockGuard {
11392            locks: &self.lock_manager,
11393            txn_id,
11394        };
11395
11396        // ── S1B-005: idempotency check BEFORE the proposal. A repeated key
11397        // with an identical request replays the original receipt without
11398        // re-executing; a different request under the same key conflicts; a
11399        // new key is reserved durably before any WAL record can become
11400        // durable, so a crash mid-commit fails closed on retry.
11401        let idempotency_request = match &context.idempotency {
11402            Some(request) => match self.idempotency.check_and_reserve(request)? {
11403                crate::txn::IdempotencyCheck::Replay(receipt) => {
11404                    let epoch = Epoch(receipt.log_position.index);
11405                    if let Some(state) = &context.state {
11406                        state.committed(receipt);
11407                    }
11408                    return Ok((epoch, Vec::new()));
11409                }
11410                crate::txn::IdempotencyCheck::Reserved => Some(request.clone()),
11411            },
11412            None => None,
11413        };
11414        // Release the reservation on every pre-receipt failure path.
11415        let mut idempotency_guard = idempotency_request.as_ref().map(|request| {
11416            crate::txn::IdempotencyReservationGuard::new(&self.idempotency, request.clone())
11417        });
11418        let mut external_states = dedup_external_states(external_states);
11419        if !external_states.is_empty() {
11420            let cat = self.catalog.read();
11421            for (name, _) in &external_states {
11422                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
11423                    return Err(MongrelError::NotFound(format!(
11424                        "external table {name:?} not found"
11425                    )));
11426                }
11427            }
11428        }
11429        let prepared_materialized_views = {
11430            let mut deduplicated = HashMap::new();
11431            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
11432            {
11433                commit_prepare_checkpoint(control, definition_index)?;
11434                if definition.name.is_empty() || definition.query.trim().is_empty() {
11435                    return Err(MongrelError::InvalidArgument(
11436                        "materialized view name and query must not be empty".into(),
11437                    ));
11438                }
11439                deduplicated.insert(definition.name.clone(), definition);
11440            }
11441            let catalog = self.catalog.read();
11442            let mut prepared = Vec::with_capacity(deduplicated.len());
11443            for (definition_index, definition) in deduplicated.into_values().enumerate() {
11444                commit_prepare_checkpoint(control, definition_index)?;
11445                let table_id = catalog
11446                    .live(&definition.name)
11447                    .ok_or_else(|| {
11448                        MongrelError::NotFound(format!(
11449                            "materialized view table {:?} not found",
11450                            definition.name
11451                        ))
11452                    })?
11453                    .table_id;
11454                prepared.push((table_id, definition));
11455            }
11456            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
11457            prepared
11458        };
11459
11460        // ── 1. Prepare: fill generated values, expand triggers, validate, then
11461        // derive write keys from the final atomic write set.
11462        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
11463        self.expand_table_triggers(
11464            txn_id,
11465            &mut staging,
11466            read_epoch,
11467            external_trigger_bridge,
11468            &mut external_states,
11469            control,
11470        )?;
11471        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
11472        external_states = dedup_external_states(external_states);
11473        let expected_external_generations = {
11474            let catalog = self.catalog.read();
11475            let mut generations = HashMap::with_capacity(external_states.len());
11476            for (name, _) in &external_states {
11477                let entry = catalog
11478                    .external_tables
11479                    .iter()
11480                    .find(|entry| entry.name == *name)
11481                    .ok_or_else(|| {
11482                        MongrelError::NotFound(format!("external table {name:?} not found"))
11483                    })?;
11484                generations.insert(name.clone(), entry.created_epoch);
11485            }
11486            generations
11487        };
11488
11489        // S1B-003: claim every unique key this transaction inserts (primary
11490        // keys and declared UNIQUE constraints) in Exclusive mode BEFORE
11491        // validation reads. Concurrent inserts of the same key serialize on
11492        // the claim instead of racing to a write-write conflict; the
11493        // first-committer-wins index below remains the safety net.
11494        self.acquire_unique_key_claims(txn_id, &staging, control)?;
11495        // Validate declarative constraints (unique / FK / check) under the read
11496        // snapshot, outside the WAL mutex. Trigger-produced writes are included
11497        // here, so the batch either satisfies every declared constraint or is
11498        // rejected atomically.
11499        self.validate_constraints(txn_id, &mut staging, read_epoch, control)?;
11500        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
11501        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
11502        let mut normalized = Vec::with_capacity(staging.len() * 2);
11503        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
11504            commit_prepare_checkpoint(control, staged_index)?;
11505            match op {
11506                crate::txn::Staged::Update {
11507                    row_id,
11508                    new_row: cells,
11509                    ..
11510                } => {
11511                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
11512                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
11513                }
11514                op => normalized.push((table_id, op)),
11515            }
11516        }
11517        staging = normalized;
11518        let has_changes = !staging.is_empty()
11519            || !external_states.is_empty()
11520            || !prepared_materialized_views.is_empty();
11521        let truncated_tables: HashSet<u64> = staging
11522            .iter()
11523            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
11524            .collect();
11525
11526        let write_keys = {
11527            let cat = self.catalog.read();
11528            let mut keys: Vec<WriteKey> = Vec::new();
11529            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11530                commit_prepare_checkpoint(control, staged_index)?;
11531                match staged {
11532                    Staged::Put(cells) => {
11533                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
11534                            for col in &entry.schema.columns {
11535                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
11536                                    if let Some((_, val)) =
11537                                        cells.iter().find(|(id, _)| *id == col.id)
11538                                    {
11539                                        let mut h = DefaultHasher::new();
11540                                        val.encode_key().hash(&mut h);
11541                                        keys.push(WriteKey::Unique {
11542                                            table_id: *table_id,
11543                                            index_id: 0,
11544                                            key_hash: h.finish(),
11545                                        });
11546                                    }
11547                                }
11548                            }
11549                            // Declared non-PK unique constraints register a
11550                            // `WriteKey::Unique` (namespace-separated from the
11551                            // PK's index_id==0 by setting the high bit) so two
11552                            // concurrent transactions inserting the same key
11553                            // cannot both commit. Rows with any NULL constrained
11554                            // column are skipped (SQL semantics).
11555                            for uc in &entry.schema.constraints.uniques {
11556                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
11557                                    &uc.columns,
11558                                    &cells.iter().cloned().collect(),
11559                                ) {
11560                                    let mut h = DefaultHasher::new();
11561                                    key_bytes.hash(&mut h);
11562                                    keys.push(WriteKey::Unique {
11563                                        table_id: *table_id,
11564                                        index_id: uc.id | 0x8000,
11565                                        key_hash: h.finish(),
11566                                    });
11567                                }
11568                            }
11569                        }
11570                    }
11571                    Staged::Delete(rid) => keys.push(WriteKey::Row {
11572                        table_id: *table_id,
11573                        row_id: rid.0,
11574                    }),
11575                    Staged::Truncate => keys.push(WriteKey::Table {
11576                        table_id: *table_id,
11577                    }),
11578                    Staged::Update { .. } => {
11579                        return Err(MongrelError::Other(
11580                            "transaction contains an unnormalized update during preparation".into(),
11581                        ));
11582                    }
11583                }
11584            }
11585            for (external_index, (name, _)) in external_states.iter().enumerate() {
11586                commit_prepare_checkpoint(control, external_index)?;
11587                let mut h = DefaultHasher::new();
11588                name.hash(&mut h);
11589                keys.push(WriteKey::Unique {
11590                    table_id: EXTERNAL_TABLE_ID,
11591                    index_id: 0,
11592                    key_hash: h.finish(),
11593                });
11594            }
11595            keys
11596        };
11597
11598        // Opportunistic pruning.
11599        let min_active = self.active_txns.min_read_epoch();
11600        if min_active < u64::MAX {
11601            self.conflicts.prune_below(Epoch(min_active));
11602        }
11603
11604        // S1B-002 (Serializable): SSI certification keys — every tracked point
11605        // read as a row key, every tracked predicate/range read as a table
11606        // key. A concurrent commit covering any of them after this
11607        // transaction's read epoch is an rw-antidependency dangerous
11608        // structure; certification aborts rather than allowing a
11609        // non-serializable interleaving.
11610        let ssi_keys = match context.isolation.canonical() {
11611            crate::txn::IsolationLevel::Serializable => {
11612                crate::txn::ssi_validation_keys(&context.read_set, &context.predicate_set)
11613            }
11614            _ => Vec::new(),
11615        };
11616
11617        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
11618        // §8.5, review fix #17). Snapshot the conflict-index version so the
11619        // sequencer only re-checks if new commits arrived in the interim.
11620        if self.conflicts.conflicts(&write_keys, read_epoch) {
11621            return Err(MongrelError::Conflict(
11622                "write-write conflict (pre-validate, first-committer-wins)".into(),
11623            ));
11624        }
11625        if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
11626            return Err(MongrelError::SerializationFailure {
11627                message: "a concurrent commit invalidated this transaction's reads (pre-validate)"
11628                    .into(),
11629            });
11630        }
11631        let pre_validate_version = self.conflicts.version();
11632
11633        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
11634        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
11635        // streamed as Put records; they are linked at publish time.
11636        let mut spilled: Vec<SpilledRun> = Vec::new();
11637        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
11638        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
11639        // as the spill runs are live (registered on first spill, dropped at the
11640        // end of this function on commit/abort/error).
11641        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
11642        {
11643            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
11644            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
11645            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11646                commit_prepare_checkpoint(control, staged_index)?;
11647                if let Staged::Put(cells) = staged {
11648                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
11649                        bytes.saturating_add(value.estimated_bytes())
11650                    });
11651                    let table_bytes = table_bytes.entry(*table_id).or_default();
11652                    *table_bytes = table_bytes.saturating_add(bytes);
11653                    put_indexes.entry(*table_id).or_default().push(staged_index);
11654                }
11655            }
11656            let tables = self.tables.read();
11657            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
11658                commit_prepare_checkpoint(control, table_index)?;
11659                if bytes
11660                    <= self
11661                        .spill_threshold
11662                        .load(std::sync::atomic::Ordering::Relaxed)
11663                {
11664                    continue;
11665                }
11666                let Some(handle) = tables.get(&table_id) else {
11667                    continue;
11668                };
11669                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
11670                let mut t = handle.lock();
11671                let tdir = t.table_dir().to_path_buf();
11672                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
11673                std::fs::create_dir_all(&txn_dir)?;
11674                let run_id = t.alloc_run_id()? as u128;
11675                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
11676                let final_path = t.run_path(run_id as u64);
11677
11678                let mut rows: Vec<Row> = Vec::new();
11679                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
11680                    commit_prepare_checkpoint(control, put_index)?;
11681                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
11682                        return Err(MongrelError::Other(
11683                            "transaction put index no longer references a put".into(),
11684                        ));
11685                    };
11686                    t.validate_cells_not_null(cells)?;
11687                    let row_id = t.alloc_row_id()?;
11688                    let mut row = Row::new(row_id, Epoch(0));
11689                    row.columns.extend(std::mem::take(cells));
11690                    rows.push(row);
11691                }
11692                let schema = t.schema_ref().clone();
11693                let kek = t.kek_ref().cloned();
11694                let specs = t.indexable_column_specs();
11695                drop(t);
11696
11697                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
11698                    .uniform_epoch(true);
11699                if let Some(ref kek) = kek {
11700                    writer = writer.with_encryption(kek.as_ref(), specs);
11701                }
11702                commit_prepare_checkpoint(control, 0)?;
11703                let header = writer.write(&pending_path, &rows)?;
11704                commit_prepare_checkpoint(control, 0)?;
11705                let row_count = header.row_count;
11706                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
11707                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
11708
11709                spilled.push(SpilledRun {
11710                    table_id,
11711                    run_id,
11712                    pending_path,
11713                    final_path,
11714                    rows,
11715                    row_count,
11716                    min_rid,
11717                    max_rid,
11718                    content_hash: header.content_hash,
11719                });
11720                spilled_tables.insert(table_id);
11721            }
11722        }
11723
11724        // Test seam: let a test race `gc()` against this in-flight spill.
11725        if spill_guard.is_some() {
11726            if let Some(hook) = self.spill_hook.lock().as_ref() {
11727                hook();
11728            }
11729        }
11730
11731        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
11732        // Allocating row ids + building the rows here (lock order: table handle →
11733        // nothing) means the sequencer never locks a table handle while holding
11734        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
11735        // the table handle THEN the shared WAL; if the sequencer did the reverse
11736        // (WAL then handle) the two paths would deadlock (review fix: B1).
11737        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
11738        // Row ids are allocated here, before the sequencer's delta conflict
11739        // re-check, so a losing txn leaks the ids it reserved — harmless, the
11740        // u64 row-id space is monotonic and gaps are expected (spills do the same).
11741        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
11742            .take(staging.len())
11743            .collect();
11744        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
11745            .take(staging.len())
11746            .collect();
11747        {
11748            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
11749            for (index, (table_id, staged)) in staging.iter().enumerate() {
11750                commit_prepare_checkpoint(control, index)?;
11751                if matches!(staged, Staged::Delete(_))
11752                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
11753                {
11754                    indexes_by_table.entry(*table_id).or_default().push(index);
11755                }
11756            }
11757            let tables = self.tables.read();
11758            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
11759                commit_prepare_checkpoint(control, table_index)?;
11760                let handle = tables.get(&table_id).ok_or_else(|| {
11761                    MongrelError::NotFound(format!("table {table_id} not mounted"))
11762                })?;
11763                #[cfg(test)]
11764                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
11765                let mut t = handle.lock();
11766                for (prepare_index, index) in indexes.into_iter().enumerate() {
11767                    commit_prepare_checkpoint(control, prepare_index)?;
11768                    match &staging[index].1 {
11769                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
11770                            t.validate_cells_not_null(cells)?;
11771                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
11772                            for (column, value) in cells {
11773                                row.columns.insert(*column, value.clone());
11774                            }
11775                            prebuilt[index] = Some(row);
11776                        }
11777                        Staged::Delete(row_id) => {
11778                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
11779                        }
11780                        Staged::Put(_) | Staged::Truncate => {}
11781                        Staged::Update { .. } => {
11782                            return Err(MongrelError::Other(
11783                                "transaction contains an unnormalized update during row preparation"
11784                                    .into(),
11785                            ));
11786                        }
11787                    }
11788                }
11789            }
11790        }
11791
11792        // Finish every fallible index read before the commit marker can become
11793        // durable. Post-durable row/run metadata application is then entirely
11794        // in-memory and cannot stop halfway through a multi-table publish.
11795        let prepared_table_handles = {
11796            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
11797            let put_table_ids: HashSet<u64> = staging
11798                .iter()
11799                .filter_map(|(table_id, staged)| {
11800                    matches!(staged, Staged::Put(_)).then_some(*table_id)
11801                })
11802                .collect();
11803            let tables = self.tables.read();
11804            let mut handles = HashMap::with_capacity(table_ids.len());
11805            for (table_index, table_id) in table_ids.into_iter().enumerate() {
11806                commit_prepare_checkpoint(control, table_index)?;
11807                let handle = tables.get(&table_id).ok_or_else(|| {
11808                    MongrelError::NotFound(format!("table {table_id} not mounted"))
11809                })?;
11810                if put_table_ids.contains(&table_id) {
11811                    match control {
11812                        Some(control) => {
11813                            handle.lock().prepare_durable_publish_controlled(control)?
11814                        }
11815                        None => handle.lock().prepare_durable_publish()?,
11816                    }
11817                }
11818                handles.insert(table_id, handle.clone());
11819            }
11820            handles
11821        };
11822
11823        // Link large-transaction spill files before WAL durability. The guard
11824        // restores their pending names on every error before WAL append begins;
11825        // publication only attaches already-present files in memory.
11826        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
11827
11828        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
11829            .iter()
11830            .map(|run| {
11831                (
11832                    run.table_id,
11833                    run.rows.iter().map(|row| row.row_id).collect(),
11834                )
11835            })
11836            .collect();
11837        let committed_row_ids = staging
11838            .iter()
11839            .enumerate()
11840            .filter_map(|(index, (table_id, staged))| {
11841                if !matches!(staged, Staged::Put(_)) {
11842                    return None;
11843                }
11844                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
11845                    spilled_row_ids
11846                        .get_mut(table_id)
11847                        .and_then(VecDeque::pop_front)
11848                })
11849            })
11850            .collect();
11851
11852        let mut prepared_external = Vec::with_capacity(external_states.len());
11853        for (external_index, (name, state)) in external_states.iter().enumerate() {
11854            commit_prepare_checkpoint(control, external_index)?;
11855            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
11856            prepared_external.push((name.clone(), state.clone(), pending));
11857        }
11858
11859        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
11860        //
11861        // The schema barrier's Shared hold covered the prepare/validate phases
11862        // above (every catalog, constraint, and claim read ran under a stable
11863        // schema); the publish below is guarded by the catalog-generation
11864        // check, exactly as before S1B-003. Release the barrier here — before
11865        // the sequencer — so a DDL waiting on its Exclusive hold may proceed
11866        // while this commit publishes (the generation check resolves that
11867        // race). Unique/sequence/FK claims stay held until the commit ends.
11868        if context.state.is_some() {
11869            self.lock_manager
11870                .release(txn_id, &crate::locks::LockKey::schema_barrier());
11871        }
11872        let added_runs: Vec<crate::wal::AddedRun> = spilled
11873            .iter()
11874            .map(|s| crate::wal::AddedRun {
11875                table_id: s.table_id,
11876                run_id: s.run_id,
11877                row_count: s.row_count,
11878                level: 0,
11879                min_row_id: s.min_rid,
11880                max_row_id: s.max_rid,
11881                content_hash: s.content_hash,
11882            })
11883            .collect();
11884        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
11885            hook();
11886        }
11887        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
11888        // Security mutations cannot overtake an authorized commit before its
11889        // commit marker is durable.
11890        let security_guard = self.security_coordinator.gate.read();
11891        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
11892            return Err(MongrelError::Conflict(
11893                "security policy changed during write".into(),
11894            ));
11895        }
11896        if spill_guard.is_some() {
11897            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
11898                hook();
11899            }
11900        }
11901        let commit_guard = self.commit_lock.lock();
11902        let catalog_generation_result = (|| {
11903            {
11904                let catalog = self.catalog.read();
11905                for table_id in prepared_table_handles.keys() {
11906                    let is_current = catalog.tables.iter().any(|entry| {
11907                        entry.table_id == *table_id
11908                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
11909                    });
11910                    if !is_current {
11911                        return Err(MongrelError::Conflict(format!(
11912                            "table {table_id} changed during transaction preparation"
11913                        )));
11914                    }
11915                }
11916                for (name, created_epoch) in &expected_external_generations {
11917                    let current = catalog
11918                        .external_tables
11919                        .iter()
11920                        .find(|entry| entry.name == *name)
11921                        .map(|entry| entry.created_epoch);
11922                    if current != Some(*created_epoch) {
11923                        return Err(MongrelError::Conflict(format!(
11924                            "external table {name:?} changed during transaction preparation"
11925                        )));
11926                    }
11927                }
11928                for (table_id, definition) in &prepared_materialized_views {
11929                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
11930                    if current != Some(*table_id) {
11931                        return Err(MongrelError::Conflict(format!(
11932                            "materialized view {:?} changed during transaction preparation",
11933                            definition.name
11934                        )));
11935                    }
11936                }
11937                if trigger_catalog_binding(&catalog) != trigger_binding {
11938                    return Err(MongrelError::Conflict(
11939                        "trigger or referenced table generation changed during transaction preparation"
11940                            .into(),
11941                    ));
11942                }
11943            }
11944            let tables = self.tables.read();
11945            for (table_id, prepared) in &prepared_table_handles {
11946                if !tables
11947                    .get(table_id)
11948                    .is_some_and(|current| current.ptr_eq(prepared))
11949                {
11950                    return Err(MongrelError::Conflict(format!(
11951                        "table {table_id} mount changed during transaction preparation"
11952                    )));
11953                }
11954            }
11955            Ok(())
11956        })();
11957        if let Err(error) = catalog_generation_result {
11958            drop(commit_guard);
11959            for (_, _, pending) in &prepared_external {
11960                let _ = std::fs::remove_file(pending);
11961            }
11962            return Err(error);
11963        }
11964        // The commit lock keeps the next epoch stable while logical spill
11965        // records are serialized. Build them before taking the shared WAL
11966        // lock, and cap their aggregate memory/WAL footprint.
11967        let new_epoch = self.epoch.assigned().next();
11968        let mut spilled_wal_bytes = 0;
11969        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
11970        let spill_prepare = (|| {
11971            for run in &mut spilled {
11972                for row in &mut run.rows {
11973                    row.committed_epoch = new_epoch;
11974                }
11975                for rows in encode_spilled_row_chunks(
11976                    &run.rows,
11977                    &mut spilled_wal_bytes,
11978                    SPILLED_WAL_TOTAL_MAX_BYTES,
11979                    control,
11980                )? {
11981                    spilled_wal_records.push((
11982                        run.table_id,
11983                        Op::SpilledRows {
11984                            table_id: run.table_id,
11985                            rows,
11986                        },
11987                    ));
11988                }
11989            }
11990            Result::<()>::Ok(())
11991        })();
11992        if let Err(error) = spill_prepare {
11993            for (_, _, pending) in &prepared_external {
11994                let _ = std::fs::remove_file(pending);
11995            }
11996            return Err(error);
11997        }
11998        let (
11999            new_epoch,
12000            mut _epoch_guard,
12001            applies,
12002            committed_materialized_views,
12003            commit_seq,
12004            commit_ts,
12005        ) = {
12006            let mut wal = self.shared_wal.lock();
12007
12008            // Re-check only if the conflict index advanced since pre-validation
12009            // (bounded delta — spec §8.5, review fix #17). If the version is
12010            // unchanged, the pre-check result is still valid and the sequencer
12011            // does O(1) work regardless of write-set size.
12012            if self.conflicts.version() != pre_validate_version {
12013                if self.conflicts.conflicts(&write_keys, read_epoch) {
12014                    // Abort: this txn assigned no epoch yet. The prepared-run guard
12015                    // restores final run names to their pending paths on return.
12016                    drop(wal);
12017                    for (_, _, pending) in &prepared_external {
12018                        let _ = std::fs::remove_file(pending);
12019                    }
12020                    return Err(MongrelError::Conflict(
12021                        "write-write conflict (sequencer delta re-check)".into(),
12022                    ));
12023                }
12024                if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
12025                    // S1B-002 dangerous structure: a commit that landed between
12026                    // the pre-check and the sequencer invalidated a tracked
12027                    // read. Abort before assigning any epoch.
12028                    drop(wal);
12029                    for (_, _, pending) in &prepared_external {
12030                        let _ = std::fs::remove_file(pending);
12031                    }
12032                    return Err(MongrelError::SerializationFailure {
12033                        message:
12034                            "a concurrent commit invalidated this transaction's reads (sequencer re-check)"
12035                                .into(),
12036                    });
12037                }
12038            }
12039
12040            if let Some(control) = control {
12041                if let Err(error) = control.checkpoint() {
12042                    drop(wal);
12043                    for (_, _, pending) in &prepared_external {
12044                        let _ = std::fs::remove_file(pending);
12045                    }
12046                    return Err(error);
12047                }
12048            }
12049            let mut applies = Vec::<TableApplyBatch>::new();
12050            let mut apply_indexes = HashMap::<u64, usize>::new();
12051            let mut committed_materialized_views = Vec::new();
12052            let mut wal_records = spilled_wal_records;
12053
12054            let mut index = 0;
12055            while index < staging.len() {
12056                let table_id = staging[index].0;
12057                let handle = prepared_table_handles
12058                    .get(&table_id)
12059                    .cloned()
12060                    .ok_or_else(|| {
12061                        MongrelError::NotFound(format!("table {table_id} not prepared"))
12062                    })?;
12063                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
12064                    let index = applies.len();
12065                    applies.push(TableApplyBatch {
12066                        table_id,
12067                        handle,
12068                        ops: Vec::new(),
12069                    });
12070                    index
12071                });
12072
12073                // Skip puts for tables that were spilled — their data is in a
12074                // pending run, not in streamed Put records.
12075                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
12076                {
12077                    index += 1;
12078                    continue;
12079                }
12080
12081                match &staging[index].1 {
12082                    Staged::Put(_) => {
12083                        let mut rows = Vec::new();
12084                        while index < staging.len()
12085                            && staging[index].0 == table_id
12086                            && matches!(&staging[index].1, Staged::Put(_))
12087                        {
12088                            let mut row = prebuilt[index].take().ok_or_else(|| {
12089                                MongrelError::Other(
12090                                    "transaction prepare lost a prebuilt put row".into(),
12091                                )
12092                            })?;
12093                            row.committed_epoch = new_epoch;
12094                            rows.push(row);
12095                            index += 1;
12096                        }
12097                        let payload = bincode::serialize(&rows)
12098                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
12099                        wal_records.push((
12100                            table_id,
12101                            Op::Put {
12102                                table_id,
12103                                rows: payload,
12104                            },
12105                        ));
12106                        applies[batch_index].ops.push(StagedOp::Put(rows));
12107                    }
12108                    Staged::Delete(_) => {
12109                        let mut row_ids = Vec::new();
12110                        while index < staging.len()
12111                            && staging[index].0 == table_id
12112                            && matches!(&staging[index].1, Staged::Delete(_))
12113                        {
12114                            let Staged::Delete(row_id) = &staging[index].1 else {
12115                                return Err(MongrelError::Other(
12116                                    "transaction delete batch changed during WAL preparation"
12117                                        .into(),
12118                                ));
12119                            };
12120                            if let Some(before) = &delete_images[index] {
12121                                wal_records.push((
12122                                    table_id,
12123                                    Op::BeforeImage {
12124                                        table_id,
12125                                        row_id: *row_id,
12126                                        row: bincode::serialize(before).map_err(|error| {
12127                                            MongrelError::Other(format!(
12128                                                "before-image serialize: {error}"
12129                                            ))
12130                                        })?,
12131                                    },
12132                                ));
12133                            }
12134                            row_ids.push(*row_id);
12135                            index += 1;
12136                        }
12137                        wal_records.push((
12138                            table_id,
12139                            Op::Delete {
12140                                table_id,
12141                                row_ids: row_ids.clone(),
12142                            },
12143                        ));
12144                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
12145                    }
12146                    Staged::Truncate => {
12147                        wal_records.push((table_id, Op::TruncateTable { table_id }));
12148                        applies[batch_index].ops.push(StagedOp::Truncate);
12149                        index += 1;
12150                    }
12151                    Staged::Update { .. } => {
12152                        return Err(MongrelError::Other(
12153                            "transaction contains an unnormalized update at the sequencer".into(),
12154                        ));
12155                    }
12156                }
12157            }
12158
12159            for (name, state, _) in &prepared_external {
12160                wal_records.push((
12161                    EXTERNAL_TABLE_ID,
12162                    Op::ExternalTableState {
12163                        name: name.clone(),
12164                        state: state.clone(),
12165                    },
12166                ));
12167            }
12168
12169            for (table_id, definition) in &prepared_materialized_views {
12170                let mut definition = definition.clone();
12171                definition.last_refresh_epoch = new_epoch.0;
12172                wal_records.push((
12173                    *table_id,
12174                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
12175                        name: definition.name.clone(),
12176                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
12177                    }),
12178                ));
12179                committed_materialized_views.push(definition);
12180            }
12181            if !committed_materialized_views.is_empty() {
12182                let mut next_catalog = self.catalog.read().clone();
12183                for definition in &committed_materialized_views {
12184                    if let Some(existing) = next_catalog
12185                        .materialized_views
12186                        .iter_mut()
12187                        .find(|existing| existing.name == definition.name)
12188                    {
12189                        *existing = definition.clone();
12190                    } else {
12191                        next_catalog.materialized_views.push(definition.clone());
12192                    }
12193                }
12194                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
12195                wal_records.push((
12196                    WAL_TABLE_ID,
12197                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
12198                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
12199                    }),
12200                ));
12201            }
12202
12203            if let Some(control) = control {
12204                if let Err(error) = control.checkpoint() {
12205                    drop(wal);
12206                    for (_, _, pending) in &prepared_external {
12207                        let _ = std::fs::remove_file(pending);
12208                    }
12209                    return Err(error);
12210                }
12211            }
12212            if let Some(before_commit) = before_commit.as_mut() {
12213                if let Err(error) = before_commit() {
12214                    drop(wal);
12215                    for (_, _, pending) in &prepared_external {
12216                        let _ = std::fs::remove_file(pending);
12217                    }
12218                    return Err(error);
12219                }
12220            }
12221
12222            let assigned_epoch = self.epoch.bump_assigned();
12223            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
12224            if assigned_epoch != new_epoch {
12225                for (_, _, pending) in &prepared_external {
12226                    let _ = std::fs::remove_file(pending);
12227                }
12228                return Err(MongrelError::Conflict(
12229                    "commit epoch changed while sequencer lock was held".into(),
12230                ));
12231            }
12232
12233            // S1B-004 steps 5–6: assign the commit timestamp (spec §8.2:
12234            // strictly greater than every participant read/write timestamp of
12235            // this transaction — the transaction's HLC read timestamp captured
12236            // at `begin` is threaded through the commit context) and enter
12237            // commit-critical state before the proposal can become durable.
12238            // Internal commits with no transaction timestamp allocate from the
12239            // fail-closed clock gate instead.
12240            let commit_ts = match context.read_ts {
12241                Some(read_ts) => self.hlc.commit_timestamp([read_ts]),
12242                None => self.hlc.now().map_err(|skew| {
12243                    MongrelError::Other(format!(
12244                        "clock skew rejected commit timestamp allocation: {skew}"
12245                    ))
12246                })?,
12247            };
12248            if let Some(state) = &context.state {
12249                state.enter_commit_critical();
12250            }
12251
12252            // From this point the outcome can become ambiguous. Keep prepared
12253            // spill files at the final names referenced by a possibly durable
12254            // commit marker; orphan cleanup is safe when the append did fail.
12255            prepared_run_links.disarm();
12256
12257            // FND-004 (spec §9.4): the commit log owns the append step for the
12258            // transaction command (records + commit marker). The commit path
12259            // proposes through it; durability and the receipt follow below.
12260            let commit_seq = self
12261                .commit_log
12262                .append_transaction(
12263                    &mut wal,
12264                    txn_id,
12265                    new_epoch,
12266                    commit_ts,
12267                    wal_records,
12268                    &added_runs,
12269                )
12270                .map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
12271
12272            // Record the conflict + assign the epoch under the WAL lock so commit
12273            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
12274            // moves out of this critical section to the group-commit coordinator
12275            // so concurrent committers share a single leader fsync.
12276            self.conflicts.record(&write_keys, new_epoch);
12277            (
12278                new_epoch,
12279                _epoch_guard,
12280                applies,
12281                committed_materialized_views,
12282                commit_seq,
12283                commit_ts,
12284            )
12285        };
12286        drop(commit_guard);
12287
12288        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
12289        let receipt =
12290            self.await_durable_commit_with_ts(txn_id, commit_seq, new_epoch, commit_ts)?;
12291        drop(security_guard);
12292
12293        // ── S1B-005: record the receipt against the reserved idempotency key
12294        // (durable before the caller can observe success), so a repeated key
12295        // with an identical request replays this receipt.
12296        if let Some(request) = &idempotency_request {
12297            if let Err(error) =
12298                self.idempotency
12299                    .complete(request, txn_id, new_epoch, receipt.commit_ts)
12300            {
12301                return Err(MongrelError::DurableCommit {
12302                    epoch: new_epoch.0,
12303                    message: format!("idempotency record was not durable: {error}"),
12304                });
12305            }
12306            if let Some(guard) = idempotency_guard.as_mut() {
12307                guard.disarm();
12308            }
12309        }
12310
12311        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
12312        let publish_result: Result<()> = {
12313            let mut first_error = None;
12314            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
12315            for run in &spilled {
12316                spilled_by_table.entry(run.table_id).or_default().push(run);
12317            }
12318            let mut modified_tables = Vec::with_capacity(applies.len());
12319            // Apply every table completely before any fallible manifest write.
12320            // The visible epoch remains unchanged until all tables are coherent.
12321            for batch in applies {
12322                #[cfg(test)]
12323                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
12324                let mut t = batch.handle.lock();
12325                for op in batch.ops {
12326                    match op {
12327                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
12328                        StagedOp::Delete(row_ids) => {
12329                            for row_id in row_ids {
12330                                t.apply_delete(row_id, new_epoch);
12331                            }
12332                        }
12333                        StagedOp::Truncate => t.apply_truncate(new_epoch),
12334                    }
12335                }
12336                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
12337                    for run in runs {
12338                        t.link_run(crate::manifest::RunRef {
12339                            run_id: run.run_id,
12340                            level: 0,
12341                            epoch_created: new_epoch.0,
12342                            row_count: run.row_count,
12343                        });
12344                        t.apply_run_metadata_prepared(&run.rows)?;
12345                        if truncated_tables.contains(&batch.table_id) {
12346                            // TRUNCATE + spilled puts fully describe this table at
12347                            // the commit epoch. Endorse the epoch so clean-reopen
12348                            // recovery does not replay the truncate over the
12349                            // already-linked replacement run.
12350                            t.set_flushed_epoch(new_epoch);
12351                        }
12352                    }
12353                }
12354                t.invalidate_pending_cache();
12355                drop(t);
12356                modified_tables.push(batch.handle);
12357            }
12358
12359            // Checkpoint only after every live table carries the durable state.
12360            // Continue after one checkpoint failure so runtime publication stays
12361            // all-or-nothing; WAL recovery repairs failed files on reopen.
12362            for handle in modified_tables {
12363                #[cfg(test)]
12364                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
12365                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
12366                    first_error.get_or_insert(error);
12367                }
12368            }
12369            for (name, _, pending) in &prepared_external {
12370                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
12371                    first_error.get_or_insert(error);
12372                }
12373            }
12374            if !committed_materialized_views.is_empty() {
12375                let mut next_catalog = self.catalog.read().clone();
12376                for definition in committed_materialized_views {
12377                    if let Some(existing) = next_catalog
12378                        .materialized_views
12379                        .iter_mut()
12380                        .find(|existing| existing.name == definition.name)
12381                    {
12382                        *existing = definition;
12383                    } else {
12384                        next_catalog.materialized_views.push(definition);
12385                    }
12386                }
12387                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
12388                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
12389                    first_error.get_or_insert(error);
12390                }
12391            }
12392            match first_error {
12393                Some(error) => Err(error),
12394                None => Ok(()),
12395            }
12396        };
12397
12398        if has_changes {
12399            let _ = self.change_wake.send(());
12400        }
12401        self.finish_durable_publish(new_epoch, &mut _epoch_guard, &receipt, publish_result)?;
12402        // S1B-001: the commit is durable and published — record the receipt on
12403        // the formal state. (Post-publish errors above return `DurableCommit`
12404        // and deliberately leave the state `CommitCritical`.)
12405        if let Some(state) = &context.state {
12406            state.committed(receipt.clone());
12407        }
12408        Ok((new_epoch, committed_row_ids))
12409    }
12410
12411    /// Register a read snapshot at the current visible epoch and return it with
12412    /// a guard that retains it for GC until dropped.
12413    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
12414        let e = self.epoch.visible();
12415        let g = self.snapshots.register(e);
12416        (Snapshot::at(e), g)
12417    }
12418
12419    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
12420    /// retention.
12421    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
12422        let e = self.epoch.visible();
12423        let g = self.snapshots.register_owned(e);
12424        (Snapshot::at(e), g)
12425    }
12426
12427    /// Configure a rolling history window measured in prior commit epochs.
12428    /// The first enable starts at the current epoch because earlier versions
12429    /// may already have been compacted. Increasing the window likewise cannot
12430    /// recreate history that fell outside the previous guarantee.
12431    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
12432        let _guard = self.ddl_lock.lock();
12433        let current = self.epoch.visible();
12434        let (old_epochs, old_start) = self.snapshots.history_config();
12435        let earliest_already_guaranteed = if old_epochs == 0 {
12436            current
12437        } else {
12438            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
12439        };
12440        let start = if epochs == 0 {
12441            current
12442        } else {
12443            earliest_already_guaranteed
12444        };
12445        let published = std::cell::Cell::new(false);
12446        let result = write_history_retention(&self.root, epochs, start, || {
12447            self.snapshots.configure_history(epochs, start);
12448            published.set(true);
12449        });
12450        match result {
12451            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
12452                epoch: current.0,
12453                message: format!("history-retention publication was not durable: {error}"),
12454            }),
12455            result => result,
12456        }
12457    }
12458
12459    pub fn history_retention_epochs(&self) -> u64 {
12460        self.snapshots.history_config().0
12461    }
12462
12463    pub fn earliest_retained_epoch(&self) -> Epoch {
12464        let current = self.epoch.visible();
12465        self.snapshots.history_floor(current).unwrap_or(current)
12466    }
12467
12468    /// Pin a guaranteed historical epoch for the lifetime of the returned
12469    /// guard. Rejects future epochs and epochs outside the configured window.
12470    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
12471        let current = self.epoch.visible();
12472        if epoch > current {
12473            return Err(MongrelError::InvalidArgument(format!(
12474                "epoch {} is in the future; current epoch is {}",
12475                epoch.0, current.0
12476            )));
12477        }
12478        let earliest = self.earliest_retained_epoch();
12479        if epoch < earliest {
12480            return Err(MongrelError::InvalidArgument(format!(
12481                "epoch {} is no longer retained; earliest available epoch is {}",
12482                epoch.0, earliest.0
12483            )));
12484        }
12485        let guard = self.snapshots.register_owned(epoch);
12486        Ok((Snapshot::at(epoch), guard))
12487    }
12488
12489    /// Names of all live tables.
12490    pub fn table_names(&self) -> Vec<String> {
12491        self.catalog
12492            .read()
12493            .tables
12494            .iter()
12495            .filter(|t| matches!(t.state, TableState::Live))
12496            .map(|t| t.name.clone())
12497            .collect()
12498    }
12499
12500    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
12501    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
12502    /// reaped on the next open. Call this as the last action before a
12503    /// short-lived process (CLI, one-shot script) exits. The daemon does not
12504    /// need this — its background auto-compactor handles run management.
12505    pub fn close(&self) -> Result<()> {
12506        for name in self.table_names() {
12507            if let Ok(handle) = self.table(&name) {
12508                if let Err(e) = handle.lock().close() {
12509                    eprintln!("[close] flush failed for {name}: {e}");
12510                }
12511            }
12512        }
12513        Ok(())
12514    }
12515
12516    /// Compact every mounted table: merge all sorted runs into one clean run
12517    /// so query cost stays flat (single-run fast path) instead of growing
12518    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
12519    /// rows to reclaim. Each table
12520    /// is locked individually for its own compaction; snapshot retention is
12521    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
12522    pub fn compact(&self) -> Result<(usize, usize)> {
12523        self.require(&crate::auth::Permission::Ddl)?;
12524        // S1A-004: admit the compaction pass as one core operation.
12525        let _operation = self.admit_operation()?;
12526        let mut compacted = 0;
12527        let mut skipped = 0;
12528        for name in self.table_names() {
12529            let Ok(handle) = self.table(&name) else {
12530                continue;
12531            };
12532            {
12533                let mut t = handle.lock();
12534                let before = t.run_count();
12535                if before < 2 && !t.should_compact() {
12536                    skipped += 1;
12537                    continue;
12538                }
12539                match t.compact() {
12540                    Ok(()) => {
12541                        let after = t.run_count();
12542                        compacted += 1;
12543                        eprintln!("[compact] {name}: {before} -> {after} runs");
12544                    }
12545                    Err(e) => {
12546                        eprintln!("[compact] {name}: compaction failed: {e}");
12547                        skipped += 1;
12548                    }
12549                }
12550            }
12551        }
12552        Ok((compacted, skipped))
12553    }
12554
12555    /// Compact a single table by name. Returns `Ok(true)` if it was
12556    /// compacted, `Ok(false)` if skipped (< 2 runs).
12557    pub fn compact_table(&self, name: &str) -> Result<bool> {
12558        self.require(&crate::auth::Permission::Ddl)?;
12559        let handle = self.table(name)?;
12560        let mut t = handle.lock();
12561        let before = t.run_count();
12562        if before < 2 {
12563            return Ok(false);
12564        }
12565        t.compact()?;
12566        Ok(t.run_count() < before)
12567    }
12568
12569    /// Look up a live table by name.
12570    pub fn table(&self, name: &str) -> Result<TableHandle> {
12571        self.ensure_owner_process()?;
12572        let cat = self.catalog.read();
12573        let entry = cat
12574            .live(name)
12575            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
12576        let id = entry.table_id;
12577        drop(cat);
12578        self.tables
12579            .read()
12580            .get(&id)
12581            .cloned()
12582            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
12583    }
12584
12585    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
12586    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
12587    pub fn has_ttl_tables(&self) -> bool {
12588        self.tables
12589            .read()
12590            .values()
12591            .any(|table| table.lock().ttl().is_some())
12592    }
12593
12594    /// Resolve a live table id → mounted handle (used by the constraint
12595    /// validation pass and other id-qualified internal paths).
12596    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
12597        self.tables
12598            .read()
12599            .get(&id)
12600            .cloned()
12601            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
12602    }
12603
12604    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
12605    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
12606    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
12607    /// allocation; explicit values merely advance the counter (an
12608    /// order-insensitive `max` under the table lock) and must not serialize.
12609    pub(crate) fn table_auto_inc_would_allocate(
12610        &self,
12611        table_id: u64,
12612        cells: &[(u16, Value)],
12613    ) -> bool {
12614        let catalog = self.catalog.read();
12615        let Some(entry) = catalog
12616            .tables
12617            .iter()
12618            .find(|entry| entry.table_id == table_id)
12619        else {
12620            return false;
12621        };
12622        let Some(column) = entry.schema.auto_increment_column() else {
12623            return false;
12624        };
12625        matches!(
12626            cells.iter().find(|(id, _)| *id == column.id),
12627            None | Some((_, Value::Null))
12628        )
12629    }
12630
12631    /// Create a new table. The DDL is first logged to the shared WAL
12632    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
12633    /// BEFORE the in-memory catalog and table map are mutated; the catalog
12634    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
12635    /// that sees a stale catalog still recovers the table by replaying the Ddl.
12636    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
12637        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
12638            return Err(MongrelError::InvalidArgument(format!(
12639                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
12640            )));
12641        }
12642        self.create_table_with_state(name, schema, TableState::Live)
12643    }
12644
12645    /// Create a durable but non-queryable CTAS build table.
12646    #[doc(hidden)]
12647    pub fn create_building_table(
12648        &self,
12649        build_name: &str,
12650        intended_name: &str,
12651        query_id: &str,
12652        schema: Schema,
12653    ) -> Result<u64> {
12654        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12655            || intended_name.is_empty()
12656            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12657            || query_id.is_empty()
12658        {
12659            return Err(MongrelError::InvalidArgument(
12660                "invalid CTAS building-table identity".into(),
12661            ));
12662        }
12663        self.create_table_with_state(
12664            build_name,
12665            schema,
12666            TableState::Building {
12667                intended_name: intended_name.to_string(),
12668                query_id: query_id.to_string(),
12669                created_at_unix_nanos: current_unix_nanos(),
12670                replaces_table_id: None,
12671            },
12672        )
12673    }
12674
12675    /// Create a hidden schema-rebuild table while the intended target remains
12676    /// live. Publication later validates that the same target is still live.
12677    #[doc(hidden)]
12678    pub fn create_rebuilding_table(
12679        &self,
12680        build_name: &str,
12681        intended_name: &str,
12682        query_id: &str,
12683        schema: Schema,
12684    ) -> Result<u64> {
12685        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12686            || intended_name.is_empty()
12687            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
12688            || query_id.is_empty()
12689        {
12690            return Err(MongrelError::InvalidArgument(
12691                "invalid rebuilding-table identity".into(),
12692            ));
12693        }
12694        let replaces_table_id = self
12695            .catalog
12696            .read()
12697            .live(intended_name)
12698            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
12699            .table_id;
12700        self.create_table_with_state(
12701            build_name,
12702            schema,
12703            TableState::Building {
12704                intended_name: intended_name.to_string(),
12705                query_id: query_id.to_string(),
12706                created_at_unix_nanos: current_unix_nanos(),
12707                replaces_table_id: Some(replaces_table_id),
12708            },
12709        )
12710    }
12711
12712    fn create_table_with_state(
12713        &self,
12714        name: &str,
12715        schema: Schema,
12716        state: TableState,
12717    ) -> Result<u64> {
12718        use crate::wal::DdlOp;
12719        use std::sync::atomic::Ordering;
12720
12721        self.require(&crate::auth::Permission::Ddl)?;
12722        if self.poisoned.load(Ordering::Relaxed) {
12723            return Err(MongrelError::Other(
12724                "database poisoned by fsync error".into(),
12725            ));
12726        }
12727        // S1A-004: admit the DDL as one core operation.
12728        let _operation = self.admit_operation()?;
12729        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
12730        let _g = self.ddl_lock.lock();
12731        let _security_write = self.security_write()?;
12732        self.require(&crate::auth::Permission::Ddl)?;
12733        {
12734            let cat = self.catalog.read();
12735            match &state {
12736                TableState::Live => {
12737                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
12738                        return Err(MongrelError::InvalidArgument(format!(
12739                            "table {name:?} already exists or is being built"
12740                        )));
12741                    }
12742                }
12743                TableState::Building {
12744                    intended_name,
12745                    replaces_table_id,
12746                    ..
12747                } => {
12748                    let target_matches = match replaces_table_id {
12749                        Some(table_id) => cat
12750                            .live(intended_name)
12751                            .is_some_and(|entry| entry.table_id == *table_id),
12752                        None => cat.live(intended_name).is_none(),
12753                    };
12754                    if !target_matches || cat.building_for(intended_name).is_some() {
12755                        return Err(MongrelError::InvalidArgument(format!(
12756                            "table {intended_name:?} changed or is already being built"
12757                        )));
12758                    }
12759                    if cat.building(name).is_some() {
12760                        return Err(MongrelError::InvalidArgument(format!(
12761                            "building table {name:?} already exists"
12762                        )));
12763                    }
12764                }
12765                TableState::Dropped { .. } => {
12766                    return Err(MongrelError::InvalidArgument(
12767                        "cannot create a dropped table".into(),
12768                    ));
12769                }
12770            }
12771        }
12772
12773        // Allocate id + epoch + txn id under the commit lock so the DDL commit
12774        // is serialized with data commits (in-order publish). Live creates
12775        // draw their table id from the routed catalog command below (S1F-001);
12776        // CTAS building-table creates keep the legacy direct allocation.
12777        let commit_lock = Arc::clone(&self.commit_lock);
12778        let _c = commit_lock.lock();
12779        let live_create = matches!(state, TableState::Live);
12780        let legacy_table_id = if live_create {
12781            None
12782        } else {
12783            let mut cat = self.catalog.write();
12784            let id = cat.next_table_id;
12785            cat.next_table_id = id
12786                .checked_add(1)
12787                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
12788            Some(id)
12789        };
12790        let epoch = self.epoch.bump_assigned();
12791        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
12792        let txn_id = self.alloc_txn_id()?;
12793
12794        let mut schema = schema;
12795        if let Some(table_id) = legacy_table_id {
12796            // Stamp the schema_id with the unique table_id so every table in
12797            // the database has a distinct schema_id (caller-provided values
12798            // are ignored to prevent collisions).
12799            schema.schema_id = table_id;
12800            // Defense in depth: reject an invalid schema BEFORE any durable
12801            // side-effect. `Table::create_in` re-validates, but by then the
12802            // DDL has already been appended to the shared WAL; a failing
12803            // create_in would leave a dangling entry that `recover_ddl_from_wal`
12804            // replays without re-validating, corrupting the catalog on reopen.
12805            // Validating here keeps the WAL free of schemas that can never be
12806            // opened.
12807            schema.validate_auto_increment()?;
12808            schema.validate_defaults()?;
12809            schema.validate_ai()?;
12810            for index in &schema.indexes {
12811                index.validate_options()?;
12812            }
12813            for constraint in &schema.constraints.checks {
12814                constraint.expr.validate()?;
12815            }
12816        }
12817
12818        let mut next_catalog = self.catalog.read().clone();
12819        let (table_id, schema) = match legacy_table_id {
12820            Some(table_id) => {
12821                next_catalog.tables.push(CatalogEntry {
12822                    table_id,
12823                    name: name.to_string(),
12824                    schema: schema.clone(),
12825                    state: state.clone(),
12826                    created_epoch: epoch.0,
12827                });
12828                (table_id, schema)
12829            }
12830            None => {
12831                // S1F-001: the mutation is a versioned catalog command —
12832                // schema validation (the same five validators), table-id
12833                // allocation, and schema_id stamping all resolve inside it.
12834                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
12835                    name: name.to_string(),
12836                    schema,
12837                    created_epoch: epoch.0,
12838                };
12839                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
12840                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
12841                    return Err(MongrelError::Other(
12842                        "CreateTable resolved to an unexpected catalog delta".into(),
12843                    ));
12844                };
12845                (entry.table_id, entry.schema)
12846            }
12847        };
12848        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
12849
12850        // Build the complete mounted table before its DDL can become durable.
12851        // Any failure removes the unpublished directory and abandons the epoch.
12852        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
12853        let canonical_tdir = self.root.join(&table_relative);
12854        let table_root = Arc::new(
12855            self.durable_root
12856                .create_directory_all_pinned(&table_relative)?,
12857        );
12858        let tdir = table_root.io_path()?;
12859        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
12860        let ctx = SharedCtx {
12861            root_guard: Some(table_root),
12862            epoch: Arc::clone(&self.epoch),
12863            page_cache: Arc::clone(&self.page_cache),
12864            decoded_cache: Arc::clone(&self.decoded_cache),
12865            snapshots: Arc::clone(&self.snapshots),
12866            kek: self.kek.clone(),
12867            commit_lock: Arc::clone(&self.commit_lock),
12868            shared: Some(crate::engine::SharedWalCtx {
12869                wal: Arc::clone(&self.shared_wal),
12870                group: Arc::clone(&self.group),
12871                poisoned: Arc::clone(&self.poisoned),
12872                txn_ids: Arc::clone(&self.next_txn_id),
12873                change_wake: self.change_wake.clone(),
12874                lifecycle: Arc::clone(&self.lifecycle),
12875            }),
12876            table_name: Some(name.to_string()),
12877            auth: self.table_auth_checker(),
12878            read_only: self.read_only,
12879        };
12880        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
12881
12882        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
12883        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
12884        let schema_json = DdlOp::encode_schema(&schema)?;
12885        let ddl = match &state {
12886            TableState::Live => DdlOp::CreateTable {
12887                table_id,
12888                name: name.to_string(),
12889                schema_json,
12890            },
12891            TableState::Building {
12892                intended_name,
12893                query_id,
12894                created_at_unix_nanos,
12895                replaces_table_id,
12896            } => match replaces_table_id {
12897                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
12898                    table_id,
12899                    build_name: name.to_string(),
12900                    intended_name: intended_name.clone(),
12901                    query_id: query_id.clone(),
12902                    created_at_unix_nanos: *created_at_unix_nanos,
12903                    replaces_table_id: *replaces_table_id,
12904                    schema_json,
12905                },
12906                None => DdlOp::CreateBuildingTable {
12907                    table_id,
12908                    build_name: name.to_string(),
12909                    intended_name: intended_name.clone(),
12910                    query_id: query_id.clone(),
12911                    created_at_unix_nanos: *created_at_unix_nanos,
12912                    schema_json,
12913                },
12914            },
12915            TableState::Dropped { .. } => {
12916                return Err(MongrelError::InvalidArgument(
12917                    "cannot create a table in dropped state".into(),
12918                ));
12919            }
12920        };
12921        let commit_seq = {
12922            let mut wal = self.shared_wal.lock();
12923            let append: Result<u64> = (|| {
12924                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
12925                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12926                wal.append_commit(txn_id, epoch, &[])
12927            })();
12928            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
12929        };
12930        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
12931        pending_table_dir.disarm();
12932
12933        // Publish the mounted table and catalog in memory even if the catalog
12934        // checkpoint fails after the WAL commit.
12935        self.tables
12936            .write()
12937            .insert(table_id, TableHandle::new(table));
12938        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12939        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
12940        Ok(table_id)
12941    }
12942
12943    /// Logically drop a table, logging the DDL through the shared WAL first.
12944    pub fn drop_table(&self, name: &str) -> Result<()> {
12945        self.drop_table_with_epoch(name).map(|_| ())
12946    }
12947
12948    /// Logically drop a table and return the exact publication epoch.
12949    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
12950        self.drop_table_with_state(name, false, None)
12951    }
12952
12953    pub fn drop_table_with_epoch_controlled<F>(
12954        &self,
12955        name: &str,
12956        mut before_commit: F,
12957    ) -> Result<Epoch>
12958    where
12959        F: FnMut() -> Result<()>,
12960    {
12961        self.drop_table_with_state(name, false, Some(&mut before_commit))
12962    }
12963
12964    /// Discard an unpublished CTAS build.
12965    #[doc(hidden)]
12966    pub fn discard_building_table(&self, name: &str) -> Result<()> {
12967        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
12968            return Err(MongrelError::InvalidArgument(
12969                "not a CTAS building table".into(),
12970            ));
12971        }
12972        self.drop_table_with_state(name, true, None).map(|_| ())
12973    }
12974
12975    fn drop_table_with_state(
12976        &self,
12977        name: &str,
12978        building: bool,
12979        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
12980    ) -> Result<Epoch> {
12981        use crate::wal::DdlOp;
12982        use std::sync::atomic::Ordering;
12983
12984        self.require(&crate::auth::Permission::Ddl)?;
12985        if self.poisoned.load(Ordering::Relaxed) {
12986            return Err(MongrelError::Other(
12987                "database poisoned by fsync error".into(),
12988            ));
12989        }
12990        // S1A-004: admit the DDL as one core operation.
12991        let _operation = self.admit_operation()?;
12992        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
12993        let _g = self.ddl_lock.lock();
12994        let _security_write = self.security_write()?;
12995        self.require(&crate::auth::Permission::Ddl)?;
12996        let table_id = {
12997            let cat = self.catalog.read();
12998            if building {
12999                cat.building(name)
13000            } else {
13001                cat.live(name)
13002            }
13003            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
13004            .table_id
13005        };
13006
13007        let commit_lock = Arc::clone(&self.commit_lock);
13008        let _c = commit_lock.lock();
13009        let epoch = self.epoch.bump_assigned();
13010        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13011        let txn_id = self.alloc_txn_id()?;
13012        let mut next_catalog = self.catalog.read().clone();
13013        if building {
13014            // CTAS building-table discards stay on the legacy mutation: the
13015            // command model's `DropTable` resolves live tables only.
13016            let entry = next_catalog
13017                .tables
13018                .iter_mut()
13019                .find(|t| t.table_id == table_id)
13020                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13021            entry.state = TableState::Dropped { at_epoch: epoch.0 };
13022            next_catalog.triggers.retain(|trigger| {
13023                !matches!(
13024                    &trigger.trigger.target,
13025                    TriggerTarget::Table(target) if target == name
13026                )
13027            });
13028            next_catalog
13029                .materialized_views
13030                .retain(|definition| definition.name != name);
13031            next_catalog
13032                .security
13033                .rls_tables
13034                .retain(|table| table != name);
13035            next_catalog
13036                .security
13037                .policies
13038                .retain(|policy| policy.table != name);
13039            next_catalog
13040                .security
13041                .masks
13042                .retain(|mask| mask.table != name);
13043            for role in &mut next_catalog.roles {
13044                role.permissions
13045                    .retain(|permission| permission_table(permission) != Some(name));
13046            }
13047            advance_security_version(&mut next_catalog)?;
13048        } else {
13049            // S1F-001: a plain live-table drop is a versioned catalog command
13050            // (the delta mirrors the legacy mutation above, cascading
13051            // triggers, materialized views, and table-scoped security state).
13052            let command = crate::catalog_cmds::CatalogCommand::DropTable {
13053                name: name.to_string(),
13054                at_epoch: epoch.0,
13055            };
13056            self.apply_catalog_command_to(&mut next_catalog, command)?;
13057        }
13058        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13059        let commit_seq = {
13060            let mut wal = self.shared_wal.lock();
13061            if let Some(before_commit) = before_commit {
13062                before_commit()?;
13063            }
13064            let append: Result<u64> = (|| {
13065                wal.append(
13066                    txn_id,
13067                    table_id,
13068                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
13069                )?;
13070                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13071                wal.append_commit(txn_id, epoch, &[])
13072            })();
13073            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13074        };
13075        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13076
13077        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
13078        self.tables.write().remove(&table_id);
13079        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
13080        Ok(epoch)
13081    }
13082
13083    /// Rename a live table. `name` must exist and `new_name` must not collide
13084    /// with any live table; both checks run under `ddl_lock` so they are atomic
13085    /// with the rename and with concurrent `create_table` existence checks (no
13086    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
13087    /// side-effects. The rename is logged to the shared WAL as
13088    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
13089    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
13090    /// the in-memory object does not move — only the catalog name changes).
13091    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
13092        self.rename_table_with_epoch(name, new_name).map(|_| ())
13093    }
13094
13095    /// Rename a table and return its exact publication epoch.
13096    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
13097        self.rename_table_with_epoch_inner(name, new_name, None)
13098    }
13099
13100    pub fn rename_table_with_epoch_controlled<F>(
13101        &self,
13102        name: &str,
13103        new_name: &str,
13104        mut before_commit: F,
13105    ) -> Result<Epoch>
13106    where
13107        F: FnMut() -> Result<()>,
13108    {
13109        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
13110    }
13111
13112    fn rename_table_with_epoch_inner(
13113        &self,
13114        name: &str,
13115        new_name: &str,
13116        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13117    ) -> Result<Epoch> {
13118        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13119            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13120        {
13121            return Err(MongrelError::InvalidArgument(
13122                "the CTAS building-table namespace is reserved".into(),
13123            ));
13124        }
13125        self.rename_table_with_state(name, new_name, false, None, before_commit)
13126    }
13127
13128    /// Atomically publish a hidden CTAS build under its intended live name.
13129    #[doc(hidden)]
13130    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
13131        self.publish_building_table_inner(build_name, new_name, None)
13132    }
13133
13134    #[doc(hidden)]
13135    pub fn publish_building_table_controlled<F>(
13136        &self,
13137        build_name: &str,
13138        new_name: &str,
13139        mut before_commit: F,
13140    ) -> Result<Epoch>
13141    where
13142        F: FnMut() -> Result<()>,
13143    {
13144        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
13145    }
13146
13147    fn publish_building_table_inner(
13148        &self,
13149        build_name: &str,
13150        new_name: &str,
13151        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13152    ) -> Result<Epoch> {
13153        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13154            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13155        {
13156            return Err(MongrelError::InvalidArgument(
13157                "invalid CTAS publish identity".into(),
13158            ));
13159        }
13160        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
13161    }
13162
13163    /// Atomically publish a hidden build and its materialized-view definition.
13164    #[doc(hidden)]
13165    pub fn publish_materialized_building_table(
13166        &self,
13167        build_name: &str,
13168        new_name: &str,
13169        definition: crate::catalog::MaterializedViewEntry,
13170    ) -> Result<Epoch> {
13171        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
13172    }
13173
13174    #[doc(hidden)]
13175    pub fn publish_materialized_building_table_controlled<F>(
13176        &self,
13177        build_name: &str,
13178        new_name: &str,
13179        definition: crate::catalog::MaterializedViewEntry,
13180        mut before_commit: F,
13181    ) -> Result<Epoch>
13182    where
13183        F: FnMut() -> Result<()>,
13184    {
13185        self.publish_materialized_building_table_inner(
13186            build_name,
13187            new_name,
13188            definition,
13189            Some(&mut before_commit),
13190        )
13191    }
13192
13193    fn publish_materialized_building_table_inner(
13194        &self,
13195        build_name: &str,
13196        new_name: &str,
13197        definition: crate::catalog::MaterializedViewEntry,
13198        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13199    ) -> Result<Epoch> {
13200        if definition.name != new_name || definition.query.trim().is_empty() {
13201            return Err(MongrelError::InvalidArgument(
13202                "invalid materialized-view publication".into(),
13203            ));
13204        }
13205        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
13206    }
13207
13208    /// Atomically replace a still-live table with its completed hidden rebuild.
13209    #[doc(hidden)]
13210    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
13211        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
13212    }
13213
13214    #[doc(hidden)]
13215    pub fn publish_rebuilding_table_controlled<F>(
13216        &self,
13217        build_name: &str,
13218        new_name: &str,
13219        mut before_commit: F,
13220    ) -> Result<Epoch>
13221    where
13222        F: FnMut() -> Result<()>,
13223    {
13224        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
13225    }
13226
13227    /// Atomically replace a live materialized-view table and its definition.
13228    #[doc(hidden)]
13229    pub fn publish_materialized_rebuilding_table_controlled<F>(
13230        &self,
13231        build_name: &str,
13232        new_name: &str,
13233        definition: crate::catalog::MaterializedViewEntry,
13234        mut before_commit: F,
13235    ) -> Result<Epoch>
13236    where
13237        F: FnMut() -> Result<()>,
13238    {
13239        self.publish_rebuilding_table_inner(
13240            build_name,
13241            new_name,
13242            Some(definition),
13243            Some(&mut before_commit),
13244        )
13245    }
13246
13247    fn publish_rebuilding_table_inner(
13248        &self,
13249        build_name: &str,
13250        new_name: &str,
13251        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
13252        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13253    ) -> Result<Epoch> {
13254        use crate::wal::DdlOp;
13255
13256        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13257            || new_name.is_empty()
13258            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13259        {
13260            return Err(MongrelError::InvalidArgument(
13261                "invalid rebuilding-table publish identity".into(),
13262            ));
13263        }
13264        if materialized_view.as_ref().is_some_and(|definition| {
13265            definition.name != new_name || definition.query.trim().is_empty()
13266        }) {
13267            return Err(MongrelError::InvalidArgument(
13268                "invalid materialized-view replacement".into(),
13269            ));
13270        }
13271        self.require(&crate::auth::Permission::Ddl)?;
13272        if self.poisoned.load(Ordering::Relaxed) {
13273            return Err(MongrelError::Other(
13274                "database poisoned by fsync error".into(),
13275            ));
13276        }
13277        // S1A-004: admit the DDL as one core operation.
13278        let _operation = self.admit_operation()?;
13279        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13280        let _ddl = self.ddl_lock.lock();
13281        let _security_write = self.security_write()?;
13282        let (table_id, replaced_table_id) = {
13283            let catalog = self.catalog.read();
13284            let build = catalog.building(build_name).ok_or_else(|| {
13285                MongrelError::NotFound(format!("building table {build_name:?} not found"))
13286            })?;
13287            let replaced_table_id = match &build.state {
13288                TableState::Building {
13289                    intended_name,
13290                    replaces_table_id: Some(replaced_table_id),
13291                    ..
13292                } if intended_name == new_name => *replaced_table_id,
13293                _ => {
13294                    return Err(MongrelError::InvalidArgument(format!(
13295                        "building table {build_name:?} is not a replacement for {new_name:?}"
13296                    )))
13297                }
13298            };
13299            if catalog
13300                .live(new_name)
13301                .is_none_or(|entry| entry.table_id != replaced_table_id)
13302            {
13303                return Err(MongrelError::Conflict(format!(
13304                    "table {new_name:?} changed while its replacement was built"
13305                )));
13306            }
13307            (build.table_id, replaced_table_id)
13308        };
13309
13310        let _commit = self.commit_lock.lock();
13311        let epoch = self.epoch.assigned().next();
13312        let txn_id = self.alloc_txn_id()?;
13313        let mut next_catalog = self.catalog.read().clone();
13314        apply_rebuilding_publish(
13315            &mut next_catalog,
13316            table_id,
13317            replaced_table_id,
13318            new_name,
13319            epoch.0,
13320        )?;
13321        if let Some(definition) = materialized_view.as_mut() {
13322            definition.last_refresh_epoch = epoch.0;
13323        }
13324        let materialized_view_json = materialized_view
13325            .as_ref()
13326            .map(DdlOp::encode_materialized_view)
13327            .transpose()?;
13328        if let Some(definition) = materialized_view {
13329            if let Some(existing) = next_catalog
13330                .materialized_views
13331                .iter_mut()
13332                .find(|existing| existing.name == definition.name)
13333            {
13334                *existing = definition;
13335            } else {
13336                next_catalog.materialized_views.push(definition);
13337            }
13338        }
13339        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13340        if let Some(before_commit) = before_commit {
13341            before_commit()?;
13342        }
13343        let assigned_epoch = self.epoch.bump_assigned();
13344        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
13345        if assigned_epoch != epoch {
13346            return Err(MongrelError::Conflict(
13347                "commit epoch changed while sequencer lock was held".into(),
13348            ));
13349        }
13350        let commit_seq = {
13351            let mut wal = self.shared_wal.lock();
13352            let append: Result<u64> = (|| {
13353                wal.append(
13354                    txn_id,
13355                    table_id,
13356                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
13357                        table_id,
13358                        replaced_table_id,
13359                        new_name: new_name.to_string(),
13360                    }),
13361                )?;
13362                if let Some(definition_json) = materialized_view_json {
13363                    wal.append(
13364                        txn_id,
13365                        table_id,
13366                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
13367                            name: new_name.to_string(),
13368                            definition_json,
13369                        }),
13370                    )?;
13371                }
13372                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13373                wal.append_commit(txn_id, epoch, &[])
13374            })();
13375            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13376        };
13377        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13378
13379        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
13380        self.tables.write().remove(&replaced_table_id);
13381        if let Some(table) = self.tables.read().get(&table_id) {
13382            table.lock().set_catalog_name(new_name.to_string());
13383        }
13384        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
13385        Ok(epoch)
13386    }
13387
13388    fn rename_table_with_state(
13389        &self,
13390        name: &str,
13391        new_name: &str,
13392        building: bool,
13393        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
13394        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13395    ) -> Result<Epoch> {
13396        use crate::wal::DdlOp;
13397        use std::sync::atomic::Ordering;
13398
13399        self.require(&crate::auth::Permission::Ddl)?;
13400        if self.poisoned.load(Ordering::Relaxed) {
13401            return Err(MongrelError::Other(
13402                "database poisoned by fsync error".into(),
13403            ));
13404        }
13405
13406        // A no-op rename short-circuits before any locking, so it can never
13407        // trip the "target already exists" check (the source *is* that name).
13408        if name == new_name {
13409            return Ok(self.visible_epoch());
13410        }
13411        if new_name.is_empty() {
13412            return Err(MongrelError::InvalidArgument(
13413                "rename_table: new name must not be empty".into(),
13414            ));
13415        }
13416        // S1A-004: admit the DDL as one core operation.
13417        let _operation = self.admit_operation()?;
13418        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13419        let _g = self.ddl_lock.lock();
13420        let _security_write = self.security_write()?;
13421        self.require(&crate::auth::Permission::Ddl)?;
13422        let table_id = {
13423            let cat = self.catalog.read();
13424            let src = if building {
13425                cat.building(name)
13426            } else {
13427                cat.live(name)
13428            }
13429            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13430            if building
13431                && !matches!(
13432                    &src.state,
13433                    TableState::Building { intended_name, .. } if intended_name == new_name
13434                )
13435            {
13436                return Err(MongrelError::InvalidArgument(format!(
13437                    "building table {name:?} is not reserved for {new_name:?}"
13438                )));
13439            }
13440            // Target must be free. Checked under ddl_lock, which every other
13441            // DDL (create/rename/drop) also holds, so a concurrent operation
13442            // cannot claim `new_name` between this check and the catalog write.
13443            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
13444                return Err(MongrelError::InvalidArgument(format!(
13445                    "rename_table: a table named {new_name:?} already exists"
13446                )));
13447            }
13448            src.table_id
13449        };
13450
13451        let commit_lock = Arc::clone(&self.commit_lock);
13452        let _c = commit_lock.lock();
13453        let epoch = self.epoch.bump_assigned();
13454        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13455        let txn_id = self.alloc_txn_id()?;
13456        if let Some(definition) = materialized_view.as_mut() {
13457            definition.last_refresh_epoch = epoch.0;
13458        }
13459        let materialized_view_json = materialized_view
13460            .as_ref()
13461            .map(DdlOp::encode_materialized_view)
13462            .transpose()?;
13463        let mut next_catalog = self.catalog.read().clone();
13464        if building {
13465            // CTAS building-table publishes stay on the legacy mutation: the
13466            // command model's `RenameTable` resolves live tables only.
13467            let entry = next_catalog
13468                .tables
13469                .iter_mut()
13470                .find(|t| t.table_id == table_id)
13471                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13472            entry.name = new_name.to_string();
13473            entry.state = TableState::Live;
13474            for trigger in &mut next_catalog.triggers {
13475                if matches!(
13476                    &trigger.trigger.target,
13477                    TriggerTarget::Table(target) if target == name
13478                ) {
13479                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
13480                }
13481            }
13482            if let Some(definition) = next_catalog
13483                .materialized_views
13484                .iter_mut()
13485                .find(|definition| definition.name == name)
13486            {
13487                definition.name = new_name.to_string();
13488            }
13489            if let Some(definition) = materialized_view.take() {
13490                next_catalog.materialized_views.push(definition);
13491            }
13492            for table in &mut next_catalog.security.rls_tables {
13493                if table == name {
13494                    *table = new_name.to_string();
13495                }
13496            }
13497            for policy in &mut next_catalog.security.policies {
13498                if policy.table == name {
13499                    policy.table = new_name.to_string();
13500                }
13501            }
13502            for mask in &mut next_catalog.security.masks {
13503                if mask.table == name {
13504                    mask.table = new_name.to_string();
13505                }
13506            }
13507            for role in &mut next_catalog.roles {
13508                for permission in &mut role.permissions {
13509                    rename_permission_table(permission, name, new_name);
13510                }
13511            }
13512            advance_security_version(&mut next_catalog)?;
13513        } else {
13514            // S1F-001: a plain live-table rename is a versioned catalog
13515            // command (the delta mirrors the legacy mutation above, including
13516            // trigger retargets and table-scoped security state).
13517            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
13518                name: name.to_string(),
13519                new_name: new_name.to_string(),
13520                at_epoch: epoch.0,
13521            };
13522            self.apply_catalog_command_to(&mut next_catalog, command)?;
13523        }
13524        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13525        let ddl = if building {
13526            DdlOp::PublishBuildingTable {
13527                table_id,
13528                new_name: new_name.to_string(),
13529            }
13530        } else {
13531            DdlOp::RenameTable {
13532                table_id,
13533                new_name: new_name.to_string(),
13534            }
13535        };
13536        let commit_seq = {
13537            let mut wal = self.shared_wal.lock();
13538            if let Some(before_commit) = before_commit {
13539                before_commit()?;
13540            }
13541            let append: Result<u64> = (|| {
13542                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
13543                if let Some(definition_json) = materialized_view_json {
13544                    wal.append(
13545                        txn_id,
13546                        table_id,
13547                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
13548                            name: new_name.to_string(),
13549                            definition_json,
13550                        }),
13551                    )?;
13552                }
13553                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13554                wal.append_commit(txn_id, epoch, &[])
13555            })();
13556            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13557        };
13558        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13559
13560        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
13561        // The in-memory table object is keyed by table_id, not name, so it does
13562        // not move and live TableHandles remain valid.
13563        if let Some(table) = self.tables.read().get(&table_id) {
13564            table.lock().set_catalog_name(new_name.to_string());
13565        }
13566        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
13567        Ok(epoch)
13568    }
13569
13570    pub fn alter_column(
13571        &self,
13572        table_name: &str,
13573        column_name: &str,
13574        change: AlterColumn,
13575    ) -> Result<ColumnDef> {
13576        self.alter_column_with_epoch(table_name, column_name, change)
13577            .map(|(column, _)| column)
13578    }
13579
13580    pub fn alter_column_with_epoch(
13581        &self,
13582        table_name: &str,
13583        column_name: &str,
13584        change: AlterColumn,
13585    ) -> Result<(ColumnDef, Option<Epoch>)> {
13586        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
13587    }
13588
13589    /// Cooperatively prepare an ALTER and fence each durable commit separately.
13590    /// `after_commit(Some(epoch))` follows an exact durable outcome;
13591    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
13592    /// for every successful `before_commit` callback.
13593    pub fn alter_column_with_epoch_controlled<B, A>(
13594        &self,
13595        table_name: &str,
13596        column_name: &str,
13597        change: AlterColumn,
13598        control: &crate::ExecutionControl,
13599        mut before_commit: B,
13600        mut after_commit: A,
13601    ) -> Result<(ColumnDef, Option<Epoch>)>
13602    where
13603        B: FnMut() -> Result<()>,
13604        A: FnMut(Option<Epoch>) -> Result<()>,
13605    {
13606        self.alter_column_with_epoch_inner(
13607            table_name,
13608            column_name,
13609            change,
13610            Some(control),
13611            Some(&mut before_commit),
13612            Some(&mut after_commit),
13613        )
13614    }
13615
13616    #[allow(clippy::too_many_arguments)]
13617    fn alter_column_with_epoch_inner(
13618        &self,
13619        table_name: &str,
13620        column_name: &str,
13621        change: AlterColumn,
13622        control: Option<&crate::ExecutionControl>,
13623        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13624        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
13625    ) -> Result<(ColumnDef, Option<Epoch>)> {
13626        use crate::wal::DdlOp;
13627        use std::sync::atomic::Ordering;
13628
13629        self.require(&crate::auth::Permission::Ddl)?;
13630        commit_prepare_checkpoint(control, 0)?;
13631        if self.poisoned.load(Ordering::Relaxed) {
13632            return Err(MongrelError::Other(
13633                "database poisoned by fsync error".into(),
13634            ));
13635        }
13636        // S1A-004: admit the DDL as one core operation.
13637        let _operation = self.admit_operation()?;
13638        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13639        let _g = self.ddl_lock.lock();
13640        let table_id = {
13641            let cat = self.catalog.read();
13642            cat.live(table_name)
13643                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
13644                .table_id
13645        };
13646        let handle =
13647            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
13648                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
13649            })?;
13650
13651        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
13652        // declared default, backfill existing NULL/absent cells as one durable
13653        // transaction before logging the metadata change. A crash between the
13654        // two commits leaves a harmless nullable-but-filled column; retry is
13655        // idempotent because only remaining NULLs are touched.
13656        let backfill = {
13657            let table = handle.lock();
13658            let old = table
13659                .schema()
13660                .column(column_name)
13661                .cloned()
13662                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
13663            let next_flags = change.flags.unwrap_or(old.flags);
13664            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
13665                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
13666                && old.default_value.is_some()
13667            {
13668                let snapshot = Snapshot::at(self.epoch.visible());
13669                let mut updates = Vec::new();
13670                let rows = match control {
13671                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
13672                    None => table.visible_rows(snapshot)?,
13673                };
13674                for (row_index, row) in rows.into_iter().enumerate() {
13675                    commit_prepare_checkpoint(control, row_index)?;
13676                    if row
13677                        .columns
13678                        .get(&old.id)
13679                        .is_some_and(|value| !matches!(value, Value::Null))
13680                    {
13681                        continue;
13682                    }
13683                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
13684                    table.apply_defaults(&mut cells)?;
13685                    updates.push((
13686                        table_id,
13687                        crate::txn::Staged::Update {
13688                            row_id: row.row_id,
13689                            new_row: cells,
13690                            changed_columns: vec![old.id],
13691                        },
13692                    ));
13693                }
13694                updates
13695            } else {
13696                Vec::new()
13697            }
13698        };
13699        let durable_epoch = std::cell::Cell::new(None);
13700        let backfill_epoch = if backfill.is_empty() {
13701            None
13702        } else {
13703            let (principal, catalog_bound) = self.transaction_principal_snapshot();
13704            let txn_id = self.alloc_txn_id()?;
13705            let mut entered_fence = false;
13706            let commit_result = match (control, before_commit.as_deref_mut()) {
13707                (Some(control), Some(before_commit)) => self
13708                    .commit_transaction_with_external_states_controlled(
13709                        txn_id,
13710                        self.epoch.visible(),
13711                        backfill,
13712                        Vec::new(),
13713                        Vec::new(),
13714                        principal.clone(),
13715                        catalog_bound,
13716                        None,
13717                        crate::txn::TxnCommitContext::internal(),
13718                        control,
13719                        &mut || {
13720                            before_commit()?;
13721                            entered_fence = true;
13722                            Ok(())
13723                        },
13724                    )
13725                    .map(|(epoch, _)| epoch),
13726                _ => self
13727                    .commit_transaction_with_external_states(
13728                        txn_id,
13729                        self.epoch.visible(),
13730                        backfill,
13731                        Vec::new(),
13732                        Vec::new(),
13733                        principal,
13734                        catalog_bound,
13735                        None,
13736                        crate::txn::TxnCommitContext::internal(),
13737                    )
13738                    .map(|(epoch, _)| epoch),
13739            };
13740            let commit_result = if entered_fence {
13741                finish_controlled_commit_attempt(commit_result, &mut after_commit)
13742            } else {
13743                commit_result
13744            };
13745            match &commit_result {
13746                Ok(epoch) => durable_epoch.set(Some(*epoch)),
13747                Err(MongrelError::DurableCommit { epoch, .. }) => {
13748                    durable_epoch.set(Some(Epoch(*epoch)));
13749                }
13750                Err(_) => {}
13751            }
13752            Some(commit_result?)
13753        };
13754        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
13755            let _security_write = self.security_write()?;
13756            self.require(&crate::auth::Permission::Ddl)?;
13757            if self
13758                .catalog
13759                .read()
13760                .live(table_name)
13761                .is_none_or(|entry| entry.table_id != table_id)
13762            {
13763                return Err(MongrelError::Conflict(format!(
13764                    "table {table_name:?} changed during ALTER"
13765                )));
13766            }
13767            let mut table = handle.lock();
13768            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
13769            let renamed_column = (column.name != column_name).then(|| column.name.clone());
13770            let Some(prepared_schema) = prepared_schema else {
13771                return Ok((column, backfill_epoch));
13772            };
13773            // S1F-001: the schema mutation is a versioned catalog command. It
13774            // validates pure against the current catalog before an epoch is
13775            // consumed; the engine's post-apply schema (schema_id bump
13776            // included) is the resolved delta the wrapper publishes.
13777            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
13778                table: table_name.to_string(),
13779                column: column.clone(),
13780            };
13781            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
13782
13783            let commit_lock = Arc::clone(&self.commit_lock);
13784            let _c = commit_lock.lock();
13785            let epoch = self.epoch.bump_assigned();
13786            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13787            let txn_id = self.alloc_txn_id()?;
13788            let column_json = DdlOp::encode_column(&column)?;
13789            let mut next_catalog = self.catalog.read().clone();
13790            let catalog_entry_index = next_catalog
13791                .tables
13792                .iter()
13793                .position(|entry| entry.table_id == table_id)
13794                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
13795            if let Some(new_column_name) = &renamed_column {
13796                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
13797                    commit_prepare_checkpoint(control, trigger_index)?;
13798                    if matches!(
13799                        &trigger.trigger.target,
13800                        TriggerTarget::Table(target) if target == table_name
13801                    ) {
13802                        trigger.trigger = trigger.trigger.renamed_update_column(
13803                            column_name,
13804                            new_column_name.clone(),
13805                            epoch.0,
13806                        )?;
13807                    }
13808                }
13809                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
13810                    commit_prepare_checkpoint(control, role_index)?;
13811                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
13812                        commit_prepare_checkpoint(control, permission_index)?;
13813                        rename_permission_column(
13814                            permission,
13815                            table_name,
13816                            column_name,
13817                            new_column_name,
13818                        );
13819                    }
13820                }
13821                advance_security_version(&mut next_catalog)?;
13822            }
13823            // Record the versioned command (validating again against the
13824            // candidate), then install the engine-resolved schema image:
13825            // identical to the command's delta when the mounted table and the
13826            // catalog are in sync, and byte-for-byte what the legacy inline
13827            // mutation published either way.
13828            self.apply_catalog_command_to(&mut next_catalog, command)?;
13829            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
13830            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13831            commit_prepare_checkpoint(control, 0)?;
13832            let mut entered_fence = false;
13833            if let Some(before_commit) = before_commit.as_deref_mut() {
13834                before_commit()?;
13835                entered_fence = true;
13836            }
13837            let commit_result: Result<Epoch> = (|| {
13838                let commit_seq = {
13839                    let mut wal = self.shared_wal.lock();
13840                    let append: Result<u64> = (|| {
13841                        wal.append(
13842                            txn_id,
13843                            table_id,
13844                            crate::wal::Op::Ddl(DdlOp::AlterTable {
13845                                table_id,
13846                                column_json,
13847                            }),
13848                        )?;
13849                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13850                        wal.append_commit(txn_id, epoch, &[])
13851                    })();
13852                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13853                };
13854                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13855                durable_epoch.set(Some(epoch));
13856
13857                table.apply_altered_schema_prepared(prepared_schema);
13858                let schema = table.schema().clone();
13859                let table_checkpoint = table.checkpoint_altered_schema();
13860                drop(table);
13861                next_catalog.tables[catalog_entry_index].schema = schema;
13862                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13863                let catalog_result =
13864                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
13865                let security_version = next_catalog.security_version;
13866                *self.catalog.write() = next_catalog;
13867                if renamed_column.is_some() {
13868                    self.security_coordinator
13869                        .version
13870                        .store(security_version, Ordering::Release);
13871                }
13872                self.publish_committed(&receipt, epoch)?;
13873                _epoch_guard.disarm();
13874                if let Err(error) = table_checkpoint.and(catalog_result) {
13875                    self.poisoned.store(true, Ordering::Relaxed);
13876                    self.lifecycle.poison();
13877                    return Err(MongrelError::DurableCommit {
13878                        epoch: epoch.0,
13879                        message: error.to_string(),
13880                    });
13881                }
13882                Ok(epoch)
13883            })();
13884            let commit_result = if entered_fence {
13885                finish_controlled_commit_attempt(commit_result, &mut after_commit)
13886            } else {
13887                commit_result
13888            };
13889            let epoch = commit_result?;
13890            Ok((column, Some(epoch)))
13891        })();
13892        result.map_err(|error| match (durable_epoch.get(), error) {
13893            (_, error @ MongrelError::DurableCommit { .. }) => error,
13894            (Some(epoch), error) => MongrelError::DurableCommit {
13895                epoch: epoch.0,
13896                message: error.to_string(),
13897            },
13898            (None, error) => error,
13899        })
13900    }
13901
13902    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
13903    /// replication. Duration is in nanoseconds.
13904    pub fn set_table_ttl(
13905        &self,
13906        table_name: &str,
13907        column_name: &str,
13908        duration_nanos: u64,
13909    ) -> Result<crate::manifest::TtlPolicy> {
13910        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
13911        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
13912    }
13913
13914    /// Set TTL metadata on a hidden build before it is published.
13915    #[doc(hidden)]
13916    pub fn set_building_table_ttl(
13917        &self,
13918        table_name: &str,
13919        column_name: &str,
13920        duration_nanos: u64,
13921    ) -> Result<crate::manifest::TtlPolicy> {
13922        let policy = self.replace_table_ttl_with_state(
13923            table_name,
13924            Some((column_name, duration_nanos)),
13925            true,
13926        )?;
13927        policy
13928            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
13929    }
13930
13931    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
13932        self.replace_table_ttl(table_name, None)?;
13933        Ok(())
13934    }
13935
13936    fn replace_table_ttl(
13937        &self,
13938        table_name: &str,
13939        requested: Option<(&str, u64)>,
13940    ) -> Result<Option<crate::manifest::TtlPolicy>> {
13941        self.replace_table_ttl_with_state(table_name, requested, false)
13942    }
13943
13944    fn replace_table_ttl_with_state(
13945        &self,
13946        table_name: &str,
13947        requested: Option<(&str, u64)>,
13948        building: bool,
13949    ) -> Result<Option<crate::manifest::TtlPolicy>> {
13950        use crate::wal::DdlOp;
13951        use std::sync::atomic::Ordering;
13952
13953        self.require(&crate::auth::Permission::Ddl)?;
13954        if self.poisoned.load(Ordering::Relaxed) {
13955            return Err(MongrelError::Other(
13956                "database poisoned by fsync error".into(),
13957            ));
13958        }
13959
13960        let _g = self.ddl_lock.lock();
13961        let _security_write = self.security_write()?;
13962        self.require(&crate::auth::Permission::Ddl)?;
13963        let table_id = {
13964            let cat = self.catalog.read();
13965            if building {
13966                cat.building(table_name)
13967            } else {
13968                cat.live(table_name)
13969            }
13970            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
13971            .table_id
13972        };
13973        let handle =
13974            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
13975                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
13976            })?;
13977        let mut table = handle.lock();
13978        let policy = match requested {
13979            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
13980            None => None,
13981        };
13982        if table.ttl() == policy {
13983            return Ok(policy);
13984        }
13985
13986        let commit_lock = Arc::clone(&self.commit_lock);
13987        let _c = commit_lock.lock();
13988        let epoch = self.epoch.bump_assigned();
13989        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13990        let txn_id = self.alloc_txn_id()?;
13991        let policy_json = DdlOp::encode_ttl(policy)?;
13992        let mut next_catalog = self.catalog.read().clone();
13993        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13994        let commit_seq = {
13995            let mut wal = self.shared_wal.lock();
13996            let append: Result<u64> = (|| {
13997                wal.append(
13998                    txn_id,
13999                    table_id,
14000                    crate::wal::Op::Ddl(DdlOp::SetTtl {
14001                        table_id,
14002                        policy_json,
14003                    }),
14004                )?;
14005                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14006                wal.append_commit(txn_id, epoch, &[])
14007            })();
14008            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14009        };
14010        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14011
14012        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
14013        drop(table);
14014        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
14015            publish_error.get_or_insert(error);
14016        }
14017        self.finish_durable_publish(
14018            epoch,
14019            &mut _epoch_guard,
14020            &receipt,
14021            publish_error.map_or(Ok(()), Err),
14022        )?;
14023        Ok(policy)
14024    }
14025
14026    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
14027    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
14028    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
14029    ///
14030    /// Returns the number of items reclaimed.
14031    pub fn gc(&self) -> Result<usize> {
14032        let control = crate::ExecutionControl::new(None);
14033        self.gc_controlled(&control, || true)
14034    }
14035
14036    /// Discover reclaimable state cooperatively, then cross one publication
14037    /// boundary immediately before the first irreversible deletion.
14038    #[doc(hidden)]
14039    pub fn gc_controlled<F>(
14040        &self,
14041        control: &crate::ExecutionControl,
14042        before_publish: F,
14043    ) -> Result<usize>
14044    where
14045        F: FnOnce() -> bool,
14046    {
14047        self.gc_controlled_with_receipt(control, before_publish)
14048            .map(|(reclaimed, _)| reclaimed)
14049    }
14050
14051    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
14052    /// return that snapshot if an irreversible deletion was attempted.
14053    #[doc(hidden)]
14054    pub fn gc_controlled_with_receipt<F>(
14055        &self,
14056        control: &crate::ExecutionControl,
14057        before_publish: F,
14058    ) -> Result<(usize, Option<MaintenanceReceipt>)>
14059    where
14060        F: FnOnce() -> bool,
14061    {
14062        enum Candidate {
14063            Directory(PathBuf),
14064            File(PathBuf),
14065        }
14066
14067        self.require(&crate::auth::Permission::Ddl)?;
14068        // S1A-004: admit the maintenance pass as one core operation.
14069        let _operation = self.admit_operation()?;
14070        let _ddl = self.ddl_lock.lock();
14071        self.require(&crate::auth::Permission::Ddl)?;
14072        control.checkpoint()?;
14073        let maintenance_epoch = self.epoch.visible();
14074        let min_active = self.snapshots.min_active(maintenance_epoch).0;
14075        let mut candidates = Vec::new();
14076
14077        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
14078        let cat = self.catalog.read();
14079        for (entry_index, entry) in cat.tables.iter().enumerate() {
14080            if entry_index % 256 == 0 {
14081                control.checkpoint()?;
14082            }
14083            if let TableState::Dropped { at_epoch } = entry.state {
14084                if at_epoch <= min_active {
14085                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
14086                    if tdir.exists() {
14087                        candidates.push(Candidate::Directory(tdir));
14088                    }
14089                }
14090            }
14091        }
14092        drop(cat);
14093
14094        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
14095        // in-flight spill's dir (deleting it would lose the pending run and fail
14096        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
14097        // skip any id still registered in `active_spills`.
14098        let cat = self.catalog.read();
14099        for (entry_index, entry) in cat.tables.iter().enumerate() {
14100            if entry_index % 256 == 0 {
14101                control.checkpoint()?;
14102            }
14103            if !matches!(entry.state, TableState::Live) {
14104                continue;
14105            }
14106            let txn_dir = self
14107                .root
14108                .join(TABLES_DIR)
14109                .join(entry.table_id.to_string())
14110                .join("_txn");
14111            if !txn_dir.exists() {
14112                continue;
14113            }
14114            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
14115                if sub_index % 256 == 0 {
14116                    control.checkpoint()?;
14117                }
14118                let sub = sub?;
14119                let name = sub.file_name();
14120                let Some(name) = name.to_str() else { continue };
14121                // A non-numeric entry can't belong to a live txn — sweep it.
14122                let is_active = name
14123                    .parse::<u64>()
14124                    .map(|id| self.active_spills.is_active(id))
14125                    .unwrap_or(false);
14126                if is_active {
14127                    continue;
14128                }
14129                candidates.push(Candidate::Directory(sub.path()));
14130            }
14131        }
14132        drop(cat);
14133
14134        let external_names = {
14135            let cat = self.catalog.read();
14136            cat.external_tables
14137                .iter()
14138                .map(|entry| entry.name.clone())
14139                .collect::<std::collections::HashSet<_>>()
14140        };
14141        let vtab_dir = self.root.join(VTAB_DIR);
14142        if vtab_dir.exists() {
14143            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
14144                if entry_index % 256 == 0 {
14145                    control.checkpoint()?;
14146                }
14147                let entry = entry?;
14148                let name = entry.file_name();
14149                let Some(name) = name.to_str() else { continue };
14150                if external_names.contains(name) {
14151                    continue;
14152                }
14153                let path = entry.path();
14154                if path.is_dir() {
14155                    candidates.push(Candidate::Directory(path));
14156                } else {
14157                    candidates.push(Candidate::File(path));
14158                }
14159            }
14160        }
14161
14162        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
14163        // can still need (spec §6.4). Each table deletes its own retired files
14164        // gated on `min_active` and persists its manifest.
14165        let tables = self
14166            .tables
14167            .read()
14168            .iter()
14169            .map(|(table_id, handle)| (*table_id, handle.clone()))
14170            .collect::<Vec<_>>();
14171        let mut retiring = Vec::new();
14172        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
14173            if table_index % 256 == 0 {
14174                control.checkpoint()?;
14175            }
14176            let backup_pinned: HashSet<u128> = self
14177                .backup_pins
14178                .lock()
14179                .keys()
14180                .filter_map(|(pinned_table, run_id)| {
14181                    (*pinned_table == *table_id).then_some(*run_id)
14182                })
14183                .collect();
14184            if handle
14185                .lock()
14186                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
14187            {
14188                retiring.push((handle.clone(), backup_pinned));
14189            }
14190        }
14191
14192        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
14193        // segment on every reopen without truncating the prior ones, so rotated
14194        // segments accumulate. Once every live table's committed data is durable
14195        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
14196        // (non-active) segments are redundant for recovery and safe to delete —
14197        // an in-flight txn only ever appends to the active segment, which is
14198        // never deleted.
14199        let all_durable = self.active_spills.is_idle()
14200            && tables.iter().all(|(_, handle)| {
14201                let g = handle.lock();
14202                g.memtable_len() == 0 && g.mutable_run_len() == 0
14203            });
14204        let retain = self
14205            .replication_wal_retention_segments
14206            .load(std::sync::atomic::Ordering::Relaxed);
14207        let reap_wal = all_durable
14208            && self
14209                .shared_wal
14210                .lock()
14211                .has_gc_segments_retain_recent(retain)?;
14212
14213        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
14214            return Ok((0, None));
14215        }
14216        control.checkpoint()?;
14217        if !before_publish() {
14218            return Err(MongrelError::Cancelled);
14219        }
14220
14221        let mut reclaimed = 0;
14222        for candidate in candidates {
14223            match candidate {
14224                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
14225                Candidate::File(path) => std::fs::remove_file(path)?,
14226            }
14227            reclaimed += 1;
14228        }
14229        for (handle, backup_pinned) in retiring {
14230            reclaimed += handle
14231                .lock()
14232                .reap_retiring(Epoch(min_active), &backup_pinned)?;
14233        }
14234        if reap_wal {
14235            reclaimed += self
14236                .shared_wal
14237                .lock()
14238                .gc_segments_retain_recent(u64::MAX, retain)?;
14239        }
14240
14241        Ok((
14242            reclaimed,
14243            Some(MaintenanceReceipt {
14244                epoch: maintenance_epoch,
14245            }),
14246        ))
14247    }
14248
14249    /// Produce a deterministic-stable byte image of the database directory.
14250    ///
14251    /// After `checkpoint()`:
14252    ///   - All pending writes are flushed to sorted runs (no memtable data).
14253    ///   - Each table is compacted to a single sorted run (no run fragmentation).
14254    ///   - All non-active WAL segments are deleted (data is durable in runs).
14255    ///   - The active WAL segment is rotated to a fresh empty segment.
14256    ///   - Dropped-table directories are removed.
14257    ///   - All manifests + catalog are persisted.
14258    ///
14259    /// The resulting directory is byte-stable: `git add` captures a snapshot
14260    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
14261    /// no unbounded segment growth, no mutable-run spill files.
14262    ///
14263    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
14264    /// It does NOT clear the exclusive lock — the caller still owns the
14265    /// database handle.
14266    pub fn checkpoint(&self) -> Result<()> {
14267        self.checkpoint_controlled(|| Ok(()))
14268    }
14269
14270    /// Strict checkpoint with a deterministic test hook after every table is
14271    /// flushed/compacted but before WAL replacement.
14272    #[doc(hidden)]
14273    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
14274    where
14275        F: FnOnce() -> Result<()>,
14276    {
14277        self.require(&crate::auth::Permission::Ddl)?;
14278        // S1A-004: admit the checkpoint as one core operation.
14279        let _operation = self.admit_operation()?;
14280        // Block cross-table commits and DDL for the full operation. Locking all
14281        // mounted handles also excludes direct `Table` commits, which do not
14282        // enter the database replication barrier.
14283        let _replication = self.replication_barrier.write();
14284        let _ddl = self.ddl_lock.lock();
14285        let _security = self.security_coordinator.gate.read();
14286        self.require(&crate::auth::Permission::Ddl)?;
14287
14288        let mut handles = self
14289            .tables
14290            .read()
14291            .iter()
14292            .map(|(table_id, handle)| (*table_id, handle.clone()))
14293            .collect::<Vec<_>>();
14294        handles.sort_by_key(|(table_id, _)| *table_id);
14295        let mut tables = handles
14296            .iter()
14297            .map(|(table_id, handle)| (*table_id, handle.lock()))
14298            .collect::<Vec<_>>();
14299
14300        // Strict flush. Any error leaves the old WAL recovery source intact.
14301        for (_, table) in &mut tables {
14302            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
14303            {
14304                table.force_flush()?;
14305            }
14306        }
14307
14308        // Strict compaction. Checkpoint never reports a stable image after a
14309        // skipped failure.
14310        for (_, table) in &mut tables {
14311            if table.run_count() >= 2 || table.should_compact() {
14312                table.compact()?;
14313            }
14314        }
14315
14316        before_wal_reset()?;
14317
14318        // Reap table-local retired runs while every table remains quiesced.
14319        let maintenance_epoch = self.epoch.visible();
14320        let min_active = self.snapshots.min_active(maintenance_epoch);
14321        for (table_id, table) in &mut tables {
14322            let backup_pinned: HashSet<u128> = self
14323                .backup_pins
14324                .lock()
14325                .keys()
14326                .filter_map(|(pinned_table, run_id)| {
14327                    (*pinned_table == *table_id).then_some(*run_id)
14328                })
14329                .collect();
14330            table.reap_retiring(min_active, &backup_pinned)?;
14331        }
14332
14333        // Publish a fresh synced active WAL, then durably reap every older
14334        // segment. This point is reached only after every strict flush succeeds.
14335        self.shared_wal.lock().reset_after_checkpoint()?;
14336
14337        // Remove catalog-unreachable directories and stale transaction state.
14338        let catalog_snapshot = self.catalog.read().clone();
14339        for entry in &catalog_snapshot.tables {
14340            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
14341                crate::durable_file::remove_directory_all(
14342                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
14343                )?;
14344            }
14345            if !matches!(entry.state, TableState::Live) {
14346                continue;
14347            }
14348            let transaction_dir = self
14349                .root
14350                .join(TABLES_DIR)
14351                .join(entry.table_id.to_string())
14352                .join("_txn");
14353            if transaction_dir.is_dir() {
14354                for child in std::fs::read_dir(&transaction_dir)? {
14355                    let child = child?;
14356                    let active = child
14357                        .file_name()
14358                        .to_str()
14359                        .and_then(|name| name.parse::<u64>().ok())
14360                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
14361                    if !active {
14362                        crate::durable_file::remove_directory_all(&child.path())?;
14363                    }
14364                }
14365            }
14366        }
14367        let external_names = catalog_snapshot
14368            .external_tables
14369            .iter()
14370            .map(|entry| entry.name.as_str())
14371            .collect::<HashSet<_>>();
14372        let external_root = self.root.join(VTAB_DIR);
14373        if external_root.is_dir() {
14374            for entry in std::fs::read_dir(&external_root)? {
14375                let entry = entry?;
14376                let name = entry.file_name();
14377                if name
14378                    .to_str()
14379                    .is_some_and(|name| external_names.contains(name))
14380                {
14381                    continue;
14382                }
14383                if entry.file_type()?.is_dir() {
14384                    crate::durable_file::remove_directory_all(&entry.path())?;
14385                } else {
14386                    std::fs::remove_file(entry.path())?;
14387                    crate::durable_file::sync_directory(&external_root)?;
14388                }
14389            }
14390        }
14391
14392        // Final authoritative metadata checkpoint while all writers remain
14393        // excluded.
14394        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
14395        let visible = self.epoch.visible();
14396        for (_, table) in &tables {
14397            table.persist_manifest(visible)?;
14398        }
14399
14400        Ok(())
14401    }
14402    fn alloc_txn_id(&self) -> Result<u64> {
14403        self.ensure_owner_process()?;
14404        crate::txn::allocate_txn_id(&self.next_txn_id)
14405    }
14406
14407    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
14408    /// (or other multi-statement lock holds). Released via
14409    /// [`Self::release_txn_locks`].
14410    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
14411        self.alloc_txn_id()
14412    }
14413
14414    /// Set the per-table spill threshold (bytes). When a transaction's staged
14415    /// bytes for a single table exceed this, the rows are written as a
14416    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
14417    pub fn set_spill_threshold(&self, bytes: u64) {
14418        self.spill_threshold
14419            .store(bytes, std::sync::atomic::Ordering::Relaxed);
14420    }
14421
14422    /// Test-only: install a hook invoked after a transaction writes its spill
14423    /// runs but before the sequencer, so a test can race `gc()` against an
14424    /// in-flight spill. Not part of the stable API.
14425    #[doc(hidden)]
14426    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14427        *self.spill_hook.lock() = Some(Box::new(f));
14428    }
14429
14430    /// Test-only: install a hook invoked while a spilled commit holds the
14431    /// security read gate and before it appends to the WAL.
14432    #[doc(hidden)]
14433    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14434        *self.security_commit_hook.lock() = Some(Box::new(f));
14435    }
14436
14437    /// Test-only: install a hook after transaction preparation and before the
14438    /// commit sequencer validates catalog generations.
14439    #[doc(hidden)]
14440    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14441        *self.catalog_commit_hook.lock() = Some(Box::new(f));
14442    }
14443
14444    /// Test-only: pause an online backup after its consistent boundary is
14445    /// captured but before the pinned immutable runs are copied.
14446    #[doc(hidden)]
14447    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14448        *self.backup_hook.lock() = Some(Box::new(f));
14449    }
14450
14451    /// Test-only: invoked after each successful FK parent-protection lock
14452    /// acquisition during constraint validation, so tests can rendezvous two
14453    /// committing transactions into a deterministic wait-for cycle.
14454    #[doc(hidden)]
14455    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14456        *self.fk_lock_hook.lock() = Some(Arc::new(f));
14457    }
14458
14459    /// Test-only: pause WAL extraction before its final principal recheck.
14460    #[doc(hidden)]
14461    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
14462        *self.replication_hook.lock() = Some(Box::new(f));
14463    }
14464
14465    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
14466    /// this stays well below the number of committed transactions when commits
14467    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
14468    #[doc(hidden)]
14469    pub fn __wal_group_sync_count(&self) -> u64 {
14470        self.shared_wal.lock().group_sync_count()
14471    }
14472
14473    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
14474    /// contract that an fsync error would trigger in production.
14475    #[doc(hidden)]
14476    pub fn __poison(&self) {
14477        self.poisoned
14478            .store(true, std::sync::atomic::Ordering::Relaxed);
14479    }
14480
14481    /// Verify multi-table integrity (spec §16). For every live table this:
14482    /// authenticates the manifest; opens each `RunRef`'s file through
14483    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
14484    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
14485    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
14486    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
14487    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
14488    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
14489    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
14490    ///
14491    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
14492    /// full body, so this is an integrity tool, not a hot path.
14493    pub fn check(&self) -> Vec<CheckIssue> {
14494        match self.check_inner(None) {
14495            Ok(issues) => issues,
14496            Err(error) => vec![CheckIssue {
14497                table_id: WAL_TABLE_ID,
14498                table_name: "shared WAL".into(),
14499                severity: "error".into(),
14500                description: error.to_string(),
14501            }],
14502        }
14503    }
14504
14505    /// Integrity check with cooperative cancellation between tables and runs.
14506    #[doc(hidden)]
14507    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
14508        self.check_inner(Some(control))
14509    }
14510
14511    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
14512        let mut issues = Vec::new();
14513        let cat = self.catalog.read();
14514        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
14515        for (table_index, entry) in cat.tables.iter().enumerate() {
14516            if table_index % 256 == 0 {
14517                if let Some(control) = control {
14518                    control.checkpoint()?;
14519                }
14520            }
14521            if !matches!(entry.state, TableState::Live) {
14522                continue;
14523            }
14524            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
14525            let mut err = |sev: &str, desc: String| {
14526                issues.push(CheckIssue {
14527                    table_id: entry.table_id,
14528                    table_name: entry.name.clone(),
14529                    severity: sev.into(),
14530                    description: desc,
14531                });
14532            };
14533            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
14534                Ok(m) => m,
14535                Err(e) => {
14536                    err("error", format!("manifest read failed: {e}"));
14537                    continue;
14538                }
14539            };
14540            if m.flushed_epoch > m.current_epoch {
14541                err(
14542                    "error",
14543                    format!(
14544                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
14545                        m.flushed_epoch, m.current_epoch
14546                    ),
14547                );
14548            }
14549
14550            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
14551            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
14552            for (run_index, rr) in m.runs.iter().enumerate() {
14553                if run_index % 256 == 0 {
14554                    if let Some(control) = control {
14555                        control.checkpoint()?;
14556                    }
14557                }
14558                referenced.insert(rr.run_id);
14559                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
14560                if !run_path.exists() {
14561                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
14562                    continue;
14563                }
14564                match crate::sorted_run::RunReader::open(
14565                    &run_path,
14566                    entry.schema.clone(),
14567                    self.kek.clone(),
14568                ) {
14569                    Ok(reader) => {
14570                        if reader.row_count() as u64 != rr.row_count {
14571                            err(
14572                                "error",
14573                                format!(
14574                                    "run r-{} row count mismatch: manifest {} vs run {}",
14575                                    rr.run_id,
14576                                    rr.row_count,
14577                                    reader.row_count()
14578                                ),
14579                            );
14580                        }
14581                    }
14582                    Err(e) => {
14583                        err(
14584                            "error",
14585                            format!("run r-{} integrity check failed: {e}", rr.run_id),
14586                        );
14587                    }
14588                }
14589            }
14590
14591            // Compaction-superseded runs awaiting retention-gated deletion are
14592            // tracked in `retiring`; their files are expected on disk, so they
14593            // are not orphans.
14594            for r in &m.retiring {
14595                referenced.insert(r.run_id);
14596            }
14597
14598            // Orphan `.sr` files present on disk but absent from the manifest.
14599            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
14600                for (entry_index, ent) in rd.flatten().enumerate() {
14601                    if entry_index % 256 == 0 {
14602                        if let Some(control) = control {
14603                            control.checkpoint()?;
14604                        }
14605                    }
14606                    let p = ent.path();
14607                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
14608                        continue;
14609                    }
14610                    let run_id = p
14611                        .file_stem()
14612                        .and_then(|s| s.to_str())
14613                        .and_then(|s| s.strip_prefix("r-"))
14614                        .and_then(|s| s.parse::<u128>().ok());
14615                    if let Some(id) = run_id {
14616                        if !referenced.contains(&id) {
14617                            err(
14618                                "warning",
14619                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
14620                            );
14621                        }
14622                    }
14623                }
14624            }
14625        }
14626
14627        let external_names = cat
14628            .external_tables
14629            .iter()
14630            .map(|entry| entry.name.clone())
14631            .collect::<std::collections::HashSet<_>>();
14632        let vtab_dir = self.root.join(VTAB_DIR);
14633        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
14634            for (entry_index, entry) in entries.flatten().enumerate() {
14635                if entry_index % 256 == 0 {
14636                    if let Some(control) = control {
14637                        control.checkpoint()?;
14638                    }
14639                }
14640                let name = entry.file_name();
14641                let Some(name) = name.to_str() else { continue };
14642                if !external_names.contains(name) {
14643                    issues.push(CheckIssue {
14644                        table_id: EXTERNAL_TABLE_ID,
14645                        table_name: name.to_string(),
14646                        severity: "warning".into(),
14647                        description: format!(
14648                            "orphan external table state entry {:?} not referenced by the catalog",
14649                            entry.path()
14650                        ),
14651                    });
14652                }
14653            }
14654        }
14655
14656        // WAL retention / integrity invariant (spec §16): every on-disk WAL
14657        // segment must open (header magic + version, and the frame cipher must
14658        // be derivable for an encrypted WAL). A segment that won't open is
14659        // corrupt or truncated and would break crash recovery. `table_id` is
14660        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
14661        // never confuses a WAL issue with a real table.
14662        if let Some(control) = control {
14663            control.checkpoint()?;
14664        }
14665        for (seg, msg) in self.shared_wal.lock().verify_segments() {
14666            issues.push(CheckIssue {
14667                table_id: WAL_TABLE_ID,
14668                table_name: "<wal>".into(),
14669                severity: "error".into(),
14670                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
14671            });
14672        }
14673        Ok(issues)
14674    }
14675
14676    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
14677    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
14678    /// unmounts them from the live table map so the DB still opens.
14679    pub fn doctor(&self) -> Result<Vec<u64>> {
14680        let control = crate::ExecutionControl::new(None);
14681        self.doctor_controlled(&control, || true)
14682    }
14683
14684    /// Check cancellably, then fence immediately before the first quarantine
14685    /// mutation. Returning `false` from `before_publish` leaves the database
14686    /// untouched.
14687    #[doc(hidden)]
14688    pub fn doctor_controlled<F>(
14689        &self,
14690        control: &crate::ExecutionControl,
14691        before_publish: F,
14692    ) -> Result<Vec<u64>>
14693    where
14694        F: FnOnce() -> bool,
14695    {
14696        self.doctor_controlled_with_receipt(control, before_publish)
14697            .map(|(quarantined, _)| quarantined)
14698    }
14699
14700    /// Check cancellably and return the exact catalog epoch used for a
14701    /// quarantine publication. No receipt is returned when nothing changes.
14702    #[doc(hidden)]
14703    pub fn doctor_controlled_with_receipt<F>(
14704        &self,
14705        control: &crate::ExecutionControl,
14706        before_publish: F,
14707    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
14708    where
14709        F: FnOnce() -> bool,
14710    {
14711        // Hold the DDL lock for the whole operation to prevent concurrent
14712        // create_table/drop_table from racing the catalog/dir mutation.
14713        let _ddl = self.ddl_lock.lock();
14714        let _security_write = self.security_write()?;
14715        let issues = self.check_inner(Some(control))?;
14716        // A corrupt WAL segment is reported as an error but is NOT a table
14717        // problem — quarantining an innocent table cannot fix it (and the first
14718        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
14719        // them disjoint). The admin must address WAL corruption manually.
14720        let bad_tables: std::collections::HashSet<u64> = issues
14721            .iter()
14722            .filter(|i| {
14723                i.severity == "error"
14724                    && i.table_id != WAL_TABLE_ID
14725                    && i.table_id != EXTERNAL_TABLE_ID
14726            })
14727            .map(|i| i.table_id)
14728            .collect();
14729        if bad_tables.is_empty() {
14730            return Ok((Vec::new(), None));
14731        }
14732        let _commit = self.commit_lock.lock();
14733        control.checkpoint()?;
14734        if !before_publish() {
14735            return Err(MongrelError::Cancelled);
14736        }
14737        let maintenance_epoch = self.epoch.bump_assigned();
14738        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
14739
14740        let qdir = self.root.join("_quarantine");
14741        crate::durable_file::create_directory(&qdir)?;
14742        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
14743        bad_tables.sort_unstable();
14744
14745        // Quiesce every mounted target before catalog publication. Existing
14746        // handle clones are marked unavailable in the publication callback so
14747        // they cannot append to the shared WAL after their catalog entry drops.
14748        let mut handles = self
14749            .tables
14750            .read()
14751            .iter()
14752            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
14753            .map(|(table_id, handle)| (*table_id, handle.clone()))
14754            .collect::<Vec<_>>();
14755        handles.sort_by_key(|(table_id, _)| *table_id);
14756        let mut table_guards = handles
14757            .iter()
14758            .map(|(table_id, handle)| (*table_id, handle.lock()))
14759            .collect::<Vec<_>>();
14760
14761        let mut next_catalog = self.catalog.read().clone();
14762        for table_id in &bad_tables {
14763            if let Some(entry) = next_catalog
14764                .tables
14765                .iter_mut()
14766                .find(|entry| entry.table_id == *table_id)
14767            {
14768                entry.state = TableState::Dropped {
14769                    at_epoch: maintenance_epoch.0,
14770                };
14771            }
14772        }
14773        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
14774
14775        let txn_id = self.alloc_txn_id()?;
14776        let commit_seq = {
14777            let mut wal = self.shared_wal.lock();
14778            let append: Result<u64> = (|| {
14779                for table_id in &bad_tables {
14780                    wal.append(
14781                        txn_id,
14782                        *table_id,
14783                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
14784                            table_id: *table_id,
14785                        }),
14786                    )?;
14787                }
14788                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14789                wal.append_commit(txn_id, maintenance_epoch, &[])
14790            })();
14791            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
14792        };
14793        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
14794        for (_, table) in &mut table_guards {
14795            table.mark_unavailable_after_quarantine();
14796        }
14797        {
14798            let mut live_tables = self.tables.write();
14799            for table_id in &bad_tables {
14800                live_tables.remove(table_id);
14801            }
14802        }
14803        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14804        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
14805
14806        // The catalog drop is durable. Directory placement is secondary but
14807        // still uses a write-through rename. A failure reports the known
14808        // catalog outcome and leaves a harmless orphan under `tables/`.
14809        for table_id in &bad_tables {
14810            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
14811            if source.exists() {
14812                let destination = qdir.join(table_id.to_string());
14813                if let Err(error) = crate::durable_file::rename(&source, &destination) {
14814                    return Err(MongrelError::DurableCommit {
14815                        epoch: maintenance_epoch.0,
14816                        message: format!(
14817                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
14818                        ),
14819                    });
14820                }
14821            }
14822        }
14823        Ok((
14824            bad_tables,
14825            Some(MaintenanceReceipt {
14826                epoch: maintenance_epoch,
14827            }),
14828        ))
14829    }
14830
14831    /// The DB-wide KEK (if encrypted).
14832    #[allow(dead_code)]
14833    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
14834        self.kek.as_ref()
14835    }
14836
14837    /// Shared epoch authority (used by the transaction layer in P2).
14838    #[allow(dead_code)]
14839    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
14840        &self.epoch
14841    }
14842
14843    /// Shared snapshot registry (used by GC in P3.6).
14844    #[allow(dead_code)]
14845    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
14846        &self.snapshots
14847    }
14848}
14849
14850fn external_state_dir(root: &Path, name: &str) -> PathBuf {
14851    root.join(VTAB_DIR).join(name)
14852}
14853
14854fn append_catalog_snapshot(
14855    wal: &mut crate::wal::SharedWal,
14856    txn_id: u64,
14857    catalog: &Catalog,
14858) -> Result<()> {
14859    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
14860    wal.append(
14861        txn_id,
14862        WAL_TABLE_ID,
14863        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
14864    )?;
14865    Ok(())
14866}
14867
14868fn filter_ignored_staging(
14869    staging: Vec<(u64, crate::txn::Staged)>,
14870    ignored_indices: &std::collections::BTreeSet<usize>,
14871) -> Vec<(u64, crate::txn::Staged)> {
14872    if ignored_indices.is_empty() {
14873        return staging;
14874    }
14875    staging
14876        .into_iter()
14877        .enumerate()
14878        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
14879        .collect()
14880}
14881
14882fn external_state_file(root: &Path, name: &str) -> PathBuf {
14883    external_state_dir(root, name).join("state.json")
14884}
14885
14886fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
14887    let path = external_state_file(root, name);
14888    match std::fs::read(path) {
14889        Ok(bytes) => Ok(bytes),
14890        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
14891        Err(e) => Err(e.into()),
14892    }
14893}
14894
14895fn current_external_state_bytes(
14896    root: &Path,
14897    external_states: &[(String, Vec<u8>)],
14898    name: &str,
14899) -> Result<Vec<u8>> {
14900    for (table, state) in external_states.iter().rev() {
14901        if table == name {
14902            return Ok(state.clone());
14903        }
14904    }
14905    read_external_state_file(root, name)
14906}
14907
14908fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
14909    let mut out = external_states;
14910    dedup_external_states_in_place(&mut out);
14911    out
14912}
14913
14914fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
14915    let mut seen = std::collections::HashSet::new();
14916    let mut out = Vec::with_capacity(external_states.len());
14917    for (name, state) in std::mem::take(external_states).into_iter().rev() {
14918        if seen.insert(name.clone()) {
14919            out.push((name, state));
14920        }
14921    }
14922    out.reverse();
14923    *external_states = out;
14924}
14925
14926fn prepare_external_state_file(
14927    root: &Path,
14928    name: &str,
14929    state: &[u8],
14930    txn_id: u64,
14931) -> Result<PathBuf> {
14932    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
14933    let dir = external_state_dir(root, name);
14934    crate::durable_file::create_directory(&dir)?;
14935    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
14936    {
14937        let mut file = std::fs::OpenOptions::new()
14938            .create_new(true)
14939            .write(true)
14940            .open(&pending)?;
14941        file.write_all(state)?;
14942        file.sync_all()?;
14943    }
14944    Ok(pending)
14945}
14946
14947fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
14948    let path = external_state_file(root, name);
14949    crate::durable_file::replace(pending, &path)?;
14950    Ok(())
14951}
14952
14953fn write_external_state_file(
14954    durable: &crate::durable_file::DurableRoot,
14955    name: &str,
14956    state: &[u8],
14957) -> Result<()> {
14958    let directory = Path::new(VTAB_DIR).join(name);
14959    durable.create_directory_all(&directory)?;
14960    durable.write_atomic(directory.join("state.json"), state)?;
14961    Ok(())
14962}
14963
14964fn validate_recovered_data_table(
14965    catalog: &Catalog,
14966    tables: &HashMap<u64, TableHandle>,
14967    table_id: u64,
14968    commit_epoch: u64,
14969    offset: u64,
14970) -> Result<bool> {
14971    let entry = catalog
14972        .tables
14973        .iter()
14974        .find(|entry| entry.table_id == table_id)
14975        .ok_or_else(|| MongrelError::CorruptWal {
14976            offset,
14977            reason: format!("committed record references unknown table {table_id}"),
14978        })?;
14979    if commit_epoch < entry.created_epoch {
14980        return Err(MongrelError::CorruptWal {
14981            offset,
14982            reason: format!(
14983                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
14984                entry.created_epoch
14985            ),
14986        });
14987    }
14988    match entry.state {
14989        TableState::Dropped { at_epoch } => {
14990            // Abandoned hidden builds are marked dropped at the last durable
14991            // boundary during open, so their final build commit may equal the
14992            // cleanup epoch. Ordinary table drops consume a new epoch and must
14993            // remain strictly later than every data commit.
14994            let abandoned_build_boundary =
14995                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
14996            if commit_epoch >= at_epoch && !abandoned_build_boundary {
14997                Err(MongrelError::CorruptWal {
14998                    offset,
14999                    reason: format!(
15000                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
15001                    ),
15002                })
15003            } else {
15004                Ok(false)
15005            }
15006        }
15007        TableState::Live | TableState::Building { .. } => {
15008            if tables.contains_key(&table_id) {
15009                Ok(true)
15010            } else {
15011                Err(MongrelError::CorruptWal {
15012                    offset,
15013                    reason: format!("live table {table_id} has no mounted recovery handle"),
15014                })
15015            }
15016        }
15017    }
15018}
15019
15020type RecoveryTableStage = (
15021    Vec<crate::memtable::Row>,
15022    Vec<(crate::rowid::RowId, Epoch)>,
15023    Option<Epoch>,
15024    Epoch,
15025);
15026
15027#[derive(Clone)]
15028struct RecoveryValidationTable {
15029    schema: Schema,
15030    flushed_epoch: u64,
15031}
15032
15033fn validate_shared_wal_recovery_plan(
15034    durable_root: &crate::durable_file::DurableRoot,
15035    catalog: &Catalog,
15036    recovered_table_ids: &HashSet<u64>,
15037    reconciled_table_ids: &HashSet<u64>,
15038    meta_dek: Option<&[u8; META_DEK_LEN]>,
15039    kek: Option<Arc<crate::encryption::Kek>>,
15040    records: &[crate::wal::Record],
15041) -> Result<()> {
15042    use crate::wal::{DdlOp, Op};
15043
15044    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
15045    for entry in &catalog.tables {
15046        if !matches!(entry.state, TableState::Live) {
15047            continue;
15048        }
15049        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15050        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
15051            Ok(manifest) => Some(manifest),
15052            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
15053            Err(error) => return Err(error),
15054        };
15055        let flushed_epoch = if let Some(manifest) = manifest {
15056            if manifest.table_id != entry.table_id {
15057                return Err(MongrelError::Conflict(format!(
15058                    "catalog table {} storage identity mismatch",
15059                    entry.table_id
15060                )));
15061            }
15062            if (manifest.schema_id != entry.schema.schema_id
15063                && !reconciled_table_ids.contains(&entry.table_id))
15064                || manifest.flushed_epoch > manifest.current_epoch
15065                || manifest.global_idx_epoch > manifest.current_epoch
15066                || manifest.next_row_id == u64::MAX
15067                || manifest.auto_inc_next < 0
15068                || manifest.auto_inc_next == i64::MAX
15069                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
15070            {
15071                return Err(MongrelError::InvalidArgument(format!(
15072                    "table {} manifest counters or schema identity are invalid",
15073                    entry.table_id
15074                )));
15075            }
15076            #[cfg(feature = "encryption")]
15077            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
15078            #[cfg(not(feature = "encryption"))]
15079            let idx_dek: Option<zeroize::Zeroizing<[u8; 32]>> = None;
15080            crate::global_idx::read_durable_for(
15081                durable_root,
15082                &relative_dir,
15083                entry.table_id,
15084                &entry.schema,
15085                idx_dek.as_deref(),
15086            )?;
15087            let mut run_ids = HashSet::new();
15088            let mut maximum_row_id = None::<u64>;
15089            for run in &manifest.runs {
15090                if run.run_id >= u64::MAX as u128
15091                    || run.epoch_created > manifest.current_epoch
15092                    || !run_ids.insert(run.run_id)
15093                {
15094                    return Err(MongrelError::InvalidArgument(format!(
15095                        "table {} manifest contains an invalid or duplicate run id",
15096                        entry.table_id
15097                    )));
15098                }
15099                let relative = relative_dir
15100                    .join(crate::engine::RUNS_DIR)
15101                    .join(format!("r-{}.sr", run.run_id as u64));
15102                let file = durable_root.open_regular(&relative)?;
15103                let mut reader = crate::sorted_run::RunReader::open_file(
15104                    file,
15105                    entry.schema.clone(),
15106                    kek.clone(),
15107                )?;
15108                let header = reader.header();
15109                if header.run_id != run.run_id
15110                    || header.level != run.level
15111                    || header.row_count != run.row_count
15112                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
15113                    || header.is_uniform_epoch() && header.epoch_created != 0
15114                    || header.schema_id > entry.schema.schema_id
15115                {
15116                    return Err(MongrelError::InvalidArgument(format!(
15117                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
15118                        entry.table_id,
15119                        run.run_id,
15120                        header.run_id,
15121                        header.level,
15122                        header.row_count,
15123                        header.epoch_created,
15124                        header.schema_id,
15125                        run.run_id,
15126                        run.level,
15127                        run.row_count,
15128                        run.epoch_created,
15129                        entry.schema.schema_id,
15130                    )));
15131                }
15132                if header.row_count != 0 {
15133                    maximum_row_id = Some(
15134                        maximum_row_id
15135                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
15136                    );
15137                }
15138                reader.validate_all_pages()?;
15139            }
15140            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
15141                return Err(MongrelError::InvalidArgument(format!(
15142                    "table {} next_row_id does not advance beyond persisted rows",
15143                    entry.table_id
15144                )));
15145            }
15146            for run in &manifest.retiring {
15147                if run.run_id >= u64::MAX as u128
15148                    || run.retire_epoch > manifest.current_epoch
15149                    || !run_ids.insert(run.run_id)
15150                {
15151                    return Err(MongrelError::InvalidArgument(format!(
15152                        "table {} manifest contains an invalid or aliased retired run",
15153                        entry.table_id
15154                    )));
15155                }
15156            }
15157            manifest.flushed_epoch
15158        } else {
15159            if !recovered_table_ids.contains(&entry.table_id) {
15160                return Err(MongrelError::NotFound(format!(
15161                    "live table {} manifest is missing",
15162                    entry.table_id
15163                )));
15164            }
15165            0
15166        };
15167        tables.insert(
15168            entry.table_id,
15169            RecoveryValidationTable {
15170                schema: entry.schema.clone(),
15171                flushed_epoch,
15172            },
15173        );
15174    }
15175
15176    let committed = records
15177        .iter()
15178        .filter_map(|record| match record.op {
15179            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
15180            _ => None,
15181        })
15182        .collect::<HashMap<_, _>>();
15183    let mut run_ids = HashSet::new();
15184    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
15185    for record in records {
15186        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
15187            continue;
15188        };
15189        match &record.op {
15190            Op::Put { table_id, rows } => {
15191                let table = validate_recovery_data_table_plan(
15192                    catalog,
15193                    &tables,
15194                    *table_id,
15195                    commit_epoch,
15196                    record.seq.0,
15197                )?;
15198                let decoded: Vec<crate::memtable::Row> =
15199                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
15200                        offset: record.seq.0,
15201                        reason: format!(
15202                            "committed Put payload for transaction {} could not be decoded: {error}",
15203                            record.txn_id
15204                        ),
15205                    })?;
15206                if let Some(table) = table {
15207                    for row in &decoded {
15208                        if !recovered_row_ids
15209                            .entry(*table_id)
15210                            .or_default()
15211                            .insert(row.row_id.0)
15212                        {
15213                            return Err(MongrelError::CorruptWal {
15214                                offset: record.seq.0,
15215                                reason: format!(
15216                                    "committed WAL repeats recovered row id {} for table {table_id}",
15217                                    row.row_id.0
15218                                ),
15219                            });
15220                        }
15221                        validate_recovered_row(&table.schema, row)?;
15222                    }
15223                }
15224            }
15225            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
15226                validate_recovery_data_table_plan(
15227                    catalog,
15228                    &tables,
15229                    *table_id,
15230                    commit_epoch,
15231                    record.seq.0,
15232                )?;
15233            }
15234            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
15235            Op::Ddl(DdlOp::ResetExternalTableState {
15236                name,
15237                generation_epoch,
15238            }) => {
15239                if *generation_epoch != commit_epoch {
15240                    return Err(MongrelError::CorruptWal {
15241                        offset: record.seq.0,
15242                        reason: format!(
15243                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
15244                        ),
15245                    });
15246                }
15247                validate_recovered_external_name(name)?;
15248            }
15249            Op::TxnCommit { added_runs, .. } => {
15250                for added in added_runs {
15251                    let Some(table) = validate_recovery_data_table_plan(
15252                        catalog,
15253                        &tables,
15254                        added.table_id,
15255                        commit_epoch,
15256                        record.seq.0,
15257                    )?
15258                    else {
15259                        continue;
15260                    };
15261                    if added.run_id >= u64::MAX as u128
15262                        || !run_ids.insert((added.table_id, added.run_id))
15263                    {
15264                        return Err(MongrelError::CorruptWal {
15265                            offset: record.seq.0,
15266                            reason: format!(
15267                                "duplicate or invalid recovered run {} for table {}",
15268                                added.run_id, added.table_id
15269                            ),
15270                        });
15271                    }
15272                    if commit_epoch <= table.flushed_epoch {
15273                        continue;
15274                    }
15275                    validate_planned_spilled_run(
15276                        durable_root,
15277                        record.txn_id,
15278                        commit_epoch,
15279                        added,
15280                        &table.schema,
15281                        kek.clone(),
15282                    )?;
15283                }
15284            }
15285            _ => {}
15286        }
15287    }
15288    Ok(())
15289}
15290
15291fn validate_recovery_data_table_plan<'a>(
15292    catalog: &Catalog,
15293    tables: &'a HashMap<u64, RecoveryValidationTable>,
15294    table_id: u64,
15295    commit_epoch: u64,
15296    offset: u64,
15297) -> Result<Option<&'a RecoveryValidationTable>> {
15298    let entry = catalog
15299        .tables
15300        .iter()
15301        .find(|entry| entry.table_id == table_id)
15302        .ok_or_else(|| MongrelError::CorruptWal {
15303            offset,
15304            reason: format!("committed record references unknown table {table_id}"),
15305        })?;
15306    if commit_epoch < entry.created_epoch {
15307        return Err(MongrelError::CorruptWal {
15308            offset,
15309            reason: format!(
15310                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
15311                entry.created_epoch
15312            ),
15313        });
15314    }
15315    match entry.state {
15316        TableState::Dropped { at_epoch } => {
15317            let abandoned =
15318                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
15319            if commit_epoch >= at_epoch && !abandoned {
15320                return Err(MongrelError::CorruptWal {
15321                    offset,
15322                    reason: format!(
15323                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
15324                    ),
15325                });
15326            }
15327            Ok(None)
15328        }
15329        TableState::Live => {
15330            tables
15331                .get(&table_id)
15332                .map(Some)
15333                .ok_or_else(|| MongrelError::CorruptWal {
15334                    offset,
15335                    reason: format!("live table {table_id} has no recovery plan"),
15336                })
15337        }
15338        TableState::Building { .. } => Err(MongrelError::CorruptWal {
15339            offset,
15340            reason: format!("building table {table_id} was not normalized before recovery"),
15341        }),
15342    }
15343}
15344
15345fn validate_planned_spilled_run(
15346    root: &crate::durable_file::DurableRoot,
15347    txn_id: u64,
15348    commit_epoch: u64,
15349    added: &crate::wal::AddedRun,
15350    schema: &Schema,
15351    kek: Option<Arc<crate::encryption::Kek>>,
15352) -> Result<()> {
15353    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
15354    let destination = table
15355        .join(crate::engine::RUNS_DIR)
15356        .join(format!("r-{}.sr", added.run_id as u64));
15357    let pending = table
15358        .join("_txn")
15359        .join(txn_id.to_string())
15360        .join(format!("r-{}.sr", added.run_id as u64));
15361    let file = match root.open_regular(&destination) {
15362        Ok(file) => file,
15363        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
15364            root.open_regular(&pending).map_err(|pending_error| {
15365                if pending_error.kind() == std::io::ErrorKind::NotFound {
15366                    MongrelError::CorruptWal {
15367                        offset: commit_epoch,
15368                        reason: format!(
15369                            "committed spilled run {} for transaction {txn_id} is missing",
15370                            added.run_id
15371                        ),
15372                    }
15373                } else {
15374                    pending_error.into()
15375                }
15376            })?
15377        }
15378        Err(error) => return Err(error.into()),
15379    };
15380    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
15381    let header = reader.header();
15382    if header.run_id != added.run_id
15383        || header.content_hash != added.content_hash
15384        || header.row_count != added.row_count
15385        || header.level != added.level
15386        || header.min_row_id != added.min_row_id
15387        || header.max_row_id != added.max_row_id
15388        || header.schema_id != schema.schema_id
15389        || !header.is_uniform_epoch()
15390        || header.epoch_created != 0
15391    {
15392        return Err(MongrelError::CorruptWal {
15393            offset: commit_epoch,
15394            reason: format!(
15395                "committed spilled run {} metadata differs from WAL",
15396                added.run_id
15397            ),
15398        });
15399    }
15400    reader.validate_all_pages()?;
15401    Ok(())
15402}
15403
15404/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
15405///
15406/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
15407/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
15408/// 2 applies each committed data record (Put/Delete) to its table at the commit
15409/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
15410/// durable in a sorted run). Finally the shared epoch authority is raised to the
15411/// max committed epoch so the next commit continues monotonically.
15412/// The staged-write payload contract of a distributed-transaction write
15413/// intent (spec section 12.8). A participant in two-phase commit stages its
15414/// writes as opaque intent payloads (`WriteIntent::value_ref` in
15415/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
15416/// this engine-defined encoding is the whole contract. At prepare time the
15417/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
15418/// committed resolution they are applied through
15419/// [`Database::apply_staged_txn_writes`].
15420///
15421/// The encoding is bincode over this enum (the same codec the WAL frame
15422/// payloads use); discriminants are never reused.
15423#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15424pub enum StagedTxnWrite {
15425    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
15426    /// identical payload shape an `Op::Put` WAL record carries). Row commit
15427    /// epochs are placeholders — the resolution apply restamps every row at
15428    /// the synthetic commit epoch.
15429    Put {
15430        /// The mounted table the rows target.
15431        table_id: u64,
15432        /// Bincode `Vec<crate::memtable::Row>`.
15433        rows: Vec<u8>,
15434    },
15435    /// Staged row deletes by row id.
15436    Delete {
15437        /// The mounted table the deletes target.
15438        table_id: u64,
15439        /// Row ids (`crate::RowId` values) to delete.
15440        row_ids: Vec<u64>,
15441    },
15442}
15443
15444impl StagedTxnWrite {
15445    /// Serializes deterministically (bincode over the enum).
15446    pub fn encode(&self) -> Result<Vec<u8>> {
15447        Ok(bincode::serialize(self)?)
15448    }
15449
15450    /// Decodes one staged-write payload, failing closed on malformed input.
15451    pub fn decode(bytes: &[u8]) -> Result<Self> {
15452        Ok(bincode::deserialize(bytes)?)
15453    }
15454}
15455
15456/// Leader-side spill translation for the replicated write path (spec section
15457/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
15458///
15459/// A transaction whose staged puts exceed the spill threshold commits with
15460/// its rows in a leader-local sorted run: the commit marker's `added_runs`
15461/// links the run file and the rows also ride the WAL as logical
15462/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
15463/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
15464/// and because the raft entry is already quorum-committed, an apply-time
15465/// rejection wedges the whole group's apply stream. The leader therefore
15466/// translates the staged record sequence **before proposal**:
15467///
15468/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
15469///   (identical row bytes; recovery restamps the rows at the commit epoch),
15470/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
15471///   reach a replica),
15472/// - every other record passes through byte-identical.
15473///
15474/// The standalone commit path is untouched: the leader's own WAL keeps the
15475/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
15476/// links the run; this function reads but never mutates its input.
15477///
15478/// Translation is total for any sequence the commit sequencer actually
15479/// produced. As a fail-closed guard against malformed or truncated captures,
15480/// the sequence is structurally validated (one transaction, exactly one
15481/// trailing commit marker), every spill payload must decode, and every linked
15482/// run's rows must be provably present as logical records (the row-id range
15483/// covers `row_count` rows); a violation rejects the proposal with
15484/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
15485/// post-commit. (Taxonomy: `InvalidArgument` maps to
15486/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
15487/// disagreement that is never retried unchanged. The normative spec category
15488/// for "the commit was not applied; only a fresh transaction may succeed" is
15489/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
15490/// `error.rs`, which is outside this change's file scope — tracked as a
15491/// follow-up.)
15492pub fn translate_records_for_replication(
15493    records: &[crate::wal::Record],
15494) -> Result<Vec<crate::wal::Record>> {
15495    use crate::wal::Op;
15496
15497    // Structural validation mirrors `apply_replicated_records`: one
15498    // transaction, exactly one commit marker, at the tail.
15499    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
15500        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
15501    })?;
15502    if records.iter().any(|record| record.txn_id != txn_id) {
15503        return Err(MongrelError::InvalidArgument(
15504            "replicated transaction payload mixes transaction ids".into(),
15505        ));
15506    }
15507    let commits = records
15508        .iter()
15509        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
15510        .count();
15511    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
15512        return Err(MongrelError::InvalidArgument(
15513            "replicated transaction payload must end in exactly one commit marker".into(),
15514        ));
15515    }
15516
15517    // Decode every logical spill payload now: a payload that cannot decode
15518    // here would fail every replica's apply identically — reject at propose
15519    // time instead.
15520    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
15521    for record in records {
15522        if let Op::SpilledRows { table_id, rows } = &record.op {
15523            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
15524                MongrelError::InvalidArgument(format!(
15525                    "spilled row payload for table {table_id} cannot decode for replication: \
15526                         {error}"
15527                ))
15528            })?;
15529            spilled_rows.entry(*table_id).or_default().extend(chunk);
15530        }
15531    }
15532
15533    // Coverage proof: every run the commit marker links must have its full
15534    // row content present as logical records, or replicas would silently
15535    // lose those rows.
15536    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
15537        unreachable!("one trailing commit marker validated above");
15538    };
15539    for run in added_runs {
15540        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
15541            MongrelError::InvalidArgument(format!(
15542                "commit links spilled run {} for table {} but carries no logical row records \
15543                 for it",
15544                run.run_id, run.table_id
15545            ))
15546        })?;
15547        let covered = rows
15548            .iter()
15549            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
15550            .count() as u64;
15551        if covered != run.row_count {
15552            return Err(MongrelError::InvalidArgument(format!(
15553                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
15554                 row records cover {} rows",
15555                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
15556            )));
15557        }
15558    }
15559
15560    // Translate: spill payloads become ordinary puts; the commit marker no
15561    // longer references leader-local run files.
15562    let translated = records
15563        .iter()
15564        .map(|record| {
15565            let op = match &record.op {
15566                Op::SpilledRows { table_id, rows } => Op::Put {
15567                    table_id: *table_id,
15568                    rows: rows.clone(),
15569                },
15570                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
15571                    epoch: *epoch,
15572                    added_runs: Vec::new(),
15573                },
15574                op => op.clone(),
15575            };
15576            crate::wal::Record::new(record.seq, record.txn_id, op)
15577        })
15578        .collect();
15579    Ok(translated)
15580}
15581
15582fn recover_shared_wal(
15583    durable_root: &crate::durable_file::DurableRoot,
15584    tables: &HashMap<u64, TableHandle>,
15585    catalog: &Catalog,
15586    epoch: &EpochAuthority,
15587    records: &[crate::wal::Record],
15588) -> Result<()> {
15589    use crate::memtable::Row;
15590    use crate::wal::{DdlOp, Op};
15591
15592    // Pass 1: committed-txn outcomes + collect spilled-run info.
15593    let mut committed: HashMap<u64, u64> = HashMap::new();
15594    let mut spilled_to_link: Vec<(
15595        u64, /*txn_id*/
15596        u64, /*epoch*/
15597        Vec<crate::wal::AddedRun>,
15598    )> = Vec::new();
15599    for r in records {
15600        if let Op::TxnCommit {
15601            epoch: ce,
15602            ref added_runs,
15603        } = r.op
15604        {
15605            committed.insert(r.txn_id, ce);
15606            if !added_runs.is_empty() {
15607                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
15608            }
15609        }
15610    }
15611    for record in records {
15612        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
15613            continue;
15614        };
15615        match &record.op {
15616            Op::Put { table_id, .. }
15617            | Op::Delete { table_id, .. }
15618            | Op::TruncateTable { table_id } => {
15619                validate_recovered_data_table(
15620                    catalog,
15621                    tables,
15622                    *table_id,
15623                    commit_epoch,
15624                    record.seq.0,
15625                )?;
15626            }
15627            Op::TxnCommit { added_runs, .. } => {
15628                for run in added_runs {
15629                    validate_recovered_data_table(
15630                        catalog,
15631                        tables,
15632                        run.table_id,
15633                        commit_epoch,
15634                        record.seq.0,
15635                    )?;
15636                }
15637            }
15638            _ => {}
15639        }
15640    }
15641    let truncated_transactions: HashSet<(u64, u64)> = records
15642        .iter()
15643        .filter_map(|record| {
15644            committed.get(&record.txn_id)?;
15645            match record.op {
15646                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
15647                _ => None,
15648            }
15649        })
15650        .collect();
15651
15652    // Pass 2: stage data per table, gated by flushed_epoch.
15653    enum ExternalRecoveryAction {
15654        Write { name: String, state: Vec<u8> },
15655        Reset { name: String },
15656    }
15657    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
15658    let mut external_actions = Vec::new();
15659    let mut max_epoch = epoch.visible().0;
15660    for r in records.iter().cloned() {
15661        let Some(&ce) = committed.get(&r.txn_id) else {
15662            continue; // aborted / in-flight — discard
15663        };
15664        let commit_epoch = Epoch(ce);
15665        max_epoch = max_epoch.max(ce);
15666        match r.op {
15667            Op::Put { table_id, rows } => {
15668                // Skip if this table already flushed past the commit epoch.
15669                let skip = tables
15670                    .get(&table_id)
15671                    .map(|h| h.lock().flushed_epoch() >= ce)
15672                    .unwrap_or(true);
15673                if skip {
15674                    continue;
15675                }
15676                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
15677                    MongrelError::CorruptWal {
15678                        offset: r.seq.0,
15679                        reason: format!(
15680                            "committed Put payload for transaction {} could not be decoded: {error}",
15681                            r.txn_id
15682                        ),
15683                    }
15684                })?;
15685                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
15686                // at pending_epoch which equals the commit epoch, but be robust).
15687                let rows: Vec<Row> = rows
15688                    .into_iter()
15689                    .map(|mut row| {
15690                        row.committed_epoch = commit_epoch;
15691                        row
15692                    })
15693                    .collect();
15694                let entry = stage
15695                    .entry(table_id)
15696                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
15697                entry.0.extend(rows);
15698                entry.3 = commit_epoch;
15699            }
15700            Op::Delete { table_id, row_ids } => {
15701                let skip = tables
15702                    .get(&table_id)
15703                    .map(|h| h.lock().flushed_epoch() >= ce)
15704                    .unwrap_or(true);
15705                if skip {
15706                    continue;
15707                }
15708                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
15709                let entry = stage
15710                    .entry(table_id)
15711                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
15712                entry.1.extend(dels);
15713                entry.3 = commit_epoch;
15714            }
15715            Op::TruncateTable { table_id } => {
15716                let skip = tables
15717                    .get(&table_id)
15718                    .map(|h| h.lock().flushed_epoch() >= ce)
15719                    .unwrap_or(true);
15720                if skip {
15721                    continue;
15722                }
15723                stage.insert(
15724                    table_id,
15725                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
15726                );
15727            }
15728            Op::ExternalTableState { name, state } => {
15729                let current_generation = catalog
15730                    .external_tables
15731                    .iter()
15732                    .find(|entry| entry.name == name)
15733                    .map(|entry| entry.created_epoch);
15734                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
15735                    validate_recovered_external_name(&name)?;
15736                    external_actions.push(ExternalRecoveryAction::Write { name, state });
15737                }
15738            }
15739            Op::Ddl(DdlOp::ResetExternalTableState {
15740                name,
15741                generation_epoch,
15742            }) => {
15743                if generation_epoch != ce {
15744                    return Err(MongrelError::CorruptWal {
15745                        offset: r.seq.0,
15746                        reason: format!(
15747                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
15748                    ),
15749                    });
15750                }
15751                validate_recovered_external_name(&name)?;
15752                external_actions.push(ExternalRecoveryAction::Reset { name });
15753            }
15754            Op::Flush { .. }
15755            | Op::TxnCommit { .. }
15756            | Op::TxnAbort
15757            | Op::Ddl(_)
15758            | Op::BeforeImage { .. }
15759            | Op::CommitTimestamp { .. }
15760            | Op::SpilledRows { .. } => {}
15761        }
15762    }
15763    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
15764        added_runs.retain(|added| {
15765            tables
15766                .get(&added.table_id)
15767                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
15768        });
15769    }
15770    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
15771    validate_recovery_table_stages(tables, &stage)?;
15772    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
15773
15774    // All WAL payloads, catalog generations, table stages, and immutable run
15775    // identities have now been validated. Only this application phase mutates
15776    // the database tree.
15777    for action in external_actions {
15778        match action {
15779            ExternalRecoveryAction::Write { name, state } => {
15780                write_external_state_file(durable_root, &name, &state)?;
15781            }
15782            ExternalRecoveryAction::Reset { name } => {
15783                durable_root.create_directory_all(VTAB_DIR)?;
15784                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
15785            }
15786        }
15787    }
15788    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
15789        let Some(handle) = tables.get(&table_id) else {
15790            continue;
15791        };
15792        let mut t = handle.lock();
15793        if let Some(epoch) = truncate_epoch {
15794            t.apply_truncate(epoch);
15795        }
15796        t.recover_apply(rows, deletes)?;
15797        // The WAL can be newer than the copied/persisted manifest after a
15798        // crash or replication apply. Rebuild O(1) count metadata from the
15799        // recovered state before endorsing the commit epoch in the manifest.
15800        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
15801        t.live_count = rows.len() as u64;
15802        // Recovery can replay older row commits while a newer spilled run is
15803        // already linked by the copied manifest. Never move that manifest's
15804        // epoch behind its existing run references.
15805        t.persist_manifest(table_epoch.max(epoch.visible()))?;
15806    }
15807
15808    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
15809    // between TxnCommit sync and the publish phase leaves the run in
15810    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
15811    for (txn_id, ce, added_runs) in &spilled_to_link {
15812        for ar in added_runs {
15813            let Some(handle) = tables.get(&ar.table_id) else {
15814                continue;
15815            };
15816            let mut t = handle.lock();
15817            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
15818            let destination = table_dir
15819                .join(crate::engine::RUNS_DIR)
15820                .join(format!("r-{}.sr", ar.run_id));
15821            match durable_root.open_regular(&destination) {
15822                Ok(_) => {}
15823                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
15824                    let pending = table_dir
15825                        .join("_txn")
15826                        .join(txn_id.to_string())
15827                        .join(format!("r-{}.sr", ar.run_id));
15828                    durable_root.rename_file_new(&pending, &destination)?;
15829                }
15830                Err(error) => return Err(error.into()),
15831            }
15832            // Only link a run whose file is actually present, and never re-link
15833            // one the publish phase already persisted into the manifest (which is
15834            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
15835            // until segment GC). `recover_spilled_run` is idempotent + reconciles
15836            // `live_count`/indexes only when the run is genuinely new.
15837            let linked = t.recover_spilled_run(crate::manifest::RunRef {
15838                run_id: ar.run_id,
15839                level: ar.level,
15840                epoch_created: *ce,
15841                row_count: ar.row_count,
15842            });
15843            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
15844            if replaced {
15845                t.set_flushed_epoch(Epoch(*ce));
15846            }
15847            if linked || replaced {
15848                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
15849            }
15850        }
15851    }
15852
15853    epoch.advance_recovered(Epoch(max_epoch));
15854    Ok(())
15855}
15856
15857fn reconcile_recovered_table_metadata(
15858    tables: &HashMap<u64, TableHandle>,
15859    epoch: Epoch,
15860) -> Result<()> {
15861    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
15862    table_ids.sort_unstable();
15863    let mut plans = Vec::with_capacity(table_ids.len());
15864    for table_id in &table_ids {
15865        let handle = tables.get(table_id).ok_or_else(|| {
15866            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
15867        })?;
15868        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
15869    }
15870    // Every table's data and metadata have been decoded successfully. Publish
15871    // repairs only after the complete database-wide plan is known valid.
15872    for (table_id, plan) in plans {
15873        let handle = tables.get(&table_id).ok_or_else(|| {
15874            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
15875        })?;
15876        handle.lock().apply_recovered_metadata(plan, epoch)?;
15877    }
15878    Ok(())
15879}
15880
15881fn validate_recovered_external_name(name: &str) -> Result<()> {
15882    if name.is_empty()
15883        || !name.chars().all(|character| {
15884            character.is_ascii_alphanumeric() || character == '_' || character == '-'
15885        })
15886    {
15887        return Err(MongrelError::CorruptWal {
15888            offset: 0,
15889            reason: format!("unsafe recovered external-table name {name:?}"),
15890        });
15891    }
15892    Ok(())
15893}
15894
15895fn validate_recovery_table_stages(
15896    tables: &HashMap<u64, TableHandle>,
15897    stages: &HashMap<u64, RecoveryTableStage>,
15898) -> Result<()> {
15899    for (table_id, (rows, _, _, _)) in stages {
15900        let handle = tables
15901            .get(table_id)
15902            .ok_or_else(|| MongrelError::CorruptWal {
15903                offset: *table_id,
15904                reason: format!("recovery stage references unmounted table {table_id}"),
15905            })?;
15906        let table = handle.lock();
15907        // Force all existing immutable runs through their integrity/decode path
15908        // before any other table manifest can be changed.
15909        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
15910        for row in rows {
15911            validate_recovered_row(table.schema(), row)?;
15912        }
15913    }
15914    Ok(())
15915}
15916
15917fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
15918    if row.deleted || row.row_id.0 == u64::MAX {
15919        return Err(MongrelError::CorruptWal {
15920            offset: row.row_id.0,
15921            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
15922        });
15923    }
15924    let cells = row
15925        .columns
15926        .iter()
15927        .map(|(column, value)| (*column, value.clone()))
15928        .collect::<Vec<_>>();
15929    schema
15930        .validate_persisted_values(&cells)
15931        .map_err(|error| MongrelError::CorruptWal {
15932            offset: row.row_id.0,
15933            reason: format!("recovered row violates table schema: {error}"),
15934        })?;
15935    if schema.auto_increment_column().is_some_and(|column| {
15936        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
15937    }) {
15938        return Err(MongrelError::CorruptWal {
15939            offset: row.row_id.0,
15940            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
15941        });
15942    }
15943    Ok(())
15944}
15945
15946fn validate_recovery_spilled_runs(
15947    root: &crate::durable_file::DurableRoot,
15948    tables: &HashMap<u64, TableHandle>,
15949    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
15950) -> Result<()> {
15951    let mut identities = HashSet::new();
15952    for (txn_id, commit_epoch, added_runs) in spilled {
15953        for added in added_runs {
15954            if added.run_id >= u64::MAX as u128 {
15955                return Err(MongrelError::CorruptWal {
15956                    offset: *commit_epoch,
15957                    reason: format!(
15958                        "recovered run id {} exceeds the on-disk namespace",
15959                        added.run_id
15960                    ),
15961                });
15962            }
15963            let Some(handle) = tables.get(&added.table_id) else {
15964                continue;
15965            };
15966            if !identities.insert((added.table_id, added.run_id)) {
15967                return Err(MongrelError::CorruptWal {
15968                    offset: *commit_epoch,
15969                    reason: format!(
15970                        "duplicate recovered run {} for table {}",
15971                        added.run_id, added.table_id
15972                    ),
15973                });
15974            }
15975            let table = handle.lock();
15976            validate_planned_spilled_run(
15977                root,
15978                *txn_id,
15979                *commit_epoch,
15980                added,
15981                table.schema(),
15982                table.kek(),
15983            )?;
15984        }
15985    }
15986    Ok(())
15987}
15988
15989fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
15990    match condition {
15991        ProcedureCondition::Pk { .. } => {
15992            if schema.primary_key().is_none() {
15993                return Err(MongrelError::InvalidArgument(
15994                    "procedure condition Pk references a table without a primary key".into(),
15995                ));
15996            }
15997        }
15998        ProcedureCondition::BitmapEq { column_id, .. }
15999        | ProcedureCondition::BitmapIn { column_id, .. }
16000        | ProcedureCondition::Range { column_id, .. }
16001        | ProcedureCondition::RangeF64 { column_id, .. }
16002        | ProcedureCondition::IsNull { column_id }
16003        | ProcedureCondition::IsNotNull { column_id }
16004        | ProcedureCondition::FmContains { column_id, .. } => {
16005            validate_column_id(*column_id, schema)?;
16006        }
16007    }
16008    Ok(())
16009}
16010
16011fn bind_procedure_args(
16012    procedure: &StoredProcedure,
16013    mut args: HashMap<String, crate::Value>,
16014) -> Result<HashMap<String, crate::Value>> {
16015    let mut out = HashMap::new();
16016    for param in &procedure.params {
16017        let value = match args.remove(&param.name) {
16018            Some(value) => value,
16019            None => param.default.clone().ok_or_else(|| {
16020                MongrelError::InvalidArgument(format!(
16021                    "missing required procedure parameter {:?}",
16022                    param.name
16023                ))
16024            })?,
16025        };
16026        if !param.nullable && matches!(value, crate::Value::Null) {
16027            return Err(MongrelError::InvalidArgument(format!(
16028                "procedure parameter {:?} must not be NULL",
16029                param.name
16030            )));
16031        }
16032        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
16033            return Err(MongrelError::InvalidArgument(format!(
16034                "procedure parameter {:?} has wrong type",
16035                param.name
16036            )));
16037        }
16038        out.insert(param.name.clone(), value);
16039    }
16040    if let Some(extra) = args.keys().next() {
16041        return Err(MongrelError::InvalidArgument(format!(
16042            "unknown procedure parameter {extra:?}"
16043        )));
16044    }
16045    Ok(out)
16046}
16047
16048fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
16049    matches!(
16050        (value, ty),
16051        (crate::Value::Bool(_), crate::TypeId::Bool)
16052            | (crate::Value::Int64(_), crate::TypeId::Int8)
16053            | (crate::Value::Int64(_), crate::TypeId::Int16)
16054            | (crate::Value::Int64(_), crate::TypeId::Int32)
16055            | (crate::Value::Int64(_), crate::TypeId::Int64)
16056            | (crate::Value::Int64(_), crate::TypeId::UInt8)
16057            | (crate::Value::Int64(_), crate::TypeId::UInt16)
16058            | (crate::Value::Int64(_), crate::TypeId::UInt32)
16059            | (crate::Value::Int64(_), crate::TypeId::UInt64)
16060            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
16061            | (crate::Value::Int64(_), crate::TypeId::Date32)
16062            | (crate::Value::Float64(_), crate::TypeId::Float32)
16063            | (crate::Value::Float64(_), crate::TypeId::Float64)
16064            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
16065            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
16066    )
16067}
16068
16069fn eval_cells(
16070    cells: &[crate::procedure::ProcedureCell],
16071    args: &HashMap<String, crate::Value>,
16072    outputs: &HashMap<String, ProcedureCallOutput>,
16073) -> Result<Vec<(u16, crate::Value)>> {
16074    cells
16075        .iter()
16076        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
16077        .collect()
16078}
16079
16080fn eval_condition(
16081    condition: &ProcedureCondition,
16082    args: &HashMap<String, crate::Value>,
16083    outputs: &HashMap<String, ProcedureCallOutput>,
16084) -> Result<crate::Condition> {
16085    Ok(match condition {
16086        ProcedureCondition::Pk { value } => {
16087            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
16088        }
16089        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
16090            column_id: *column_id,
16091            value: eval_value(value, args, outputs)?.encode_key(),
16092        },
16093        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
16094            column_id: *column_id,
16095            values: values
16096                .iter()
16097                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
16098                .collect::<Result<Vec<_>>>()?,
16099        },
16100        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
16101            column_id: *column_id,
16102            lo: expect_i64(eval_value(lo, args, outputs)?)?,
16103            hi: expect_i64(eval_value(hi, args, outputs)?)?,
16104        },
16105        ProcedureCondition::RangeF64 {
16106            column_id,
16107            lo,
16108            lo_inclusive,
16109            hi,
16110            hi_inclusive,
16111        } => crate::Condition::RangeF64 {
16112            column_id: *column_id,
16113            lo: expect_f64(eval_value(lo, args, outputs)?)?,
16114            lo_inclusive: *lo_inclusive,
16115            hi: expect_f64(eval_value(hi, args, outputs)?)?,
16116            hi_inclusive: *hi_inclusive,
16117        },
16118        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
16119            column_id: *column_id,
16120        },
16121        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
16122            column_id: *column_id,
16123        },
16124        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
16125            column_id: *column_id,
16126            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
16127        },
16128    })
16129}
16130
16131fn eval_value(
16132    value: &ProcedureValue,
16133    args: &HashMap<String, crate::Value>,
16134    outputs: &HashMap<String, ProcedureCallOutput>,
16135) -> Result<crate::Value> {
16136    match value {
16137        ProcedureValue::Literal(value) => Ok(value.clone()),
16138        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
16139            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
16140        }),
16141        ProcedureValue::StepScalar(id) => match outputs.get(id) {
16142            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
16143            _ => Err(MongrelError::InvalidArgument(format!(
16144                "procedure step {id:?} did not return a scalar"
16145            ))),
16146        },
16147        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
16148            Err(MongrelError::InvalidArgument(
16149                "row-valued procedure reference cannot be used as a scalar".into(),
16150            ))
16151        }
16152        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
16153            "structured procedure value cannot be used as a scalar cell".into(),
16154        )),
16155    }
16156}
16157
16158fn eval_return_output(
16159    value: &ProcedureValue,
16160    args: &HashMap<String, crate::Value>,
16161    outputs: &HashMap<String, ProcedureCallOutput>,
16162) -> Result<ProcedureCallOutput> {
16163    match value {
16164        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
16165        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
16166            args.get(name).cloned().ok_or_else(|| {
16167                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
16168            })?,
16169        )),
16170        ProcedureValue::StepRows(id)
16171        | ProcedureValue::StepRow(id)
16172        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
16173            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
16174        }),
16175        ProcedureValue::Object(fields) => {
16176            let mut out = Vec::with_capacity(fields.len());
16177            for (name, value) in fields {
16178                out.push((name.clone(), eval_return_output(value, args, outputs)?));
16179            }
16180            Ok(ProcedureCallOutput::Object(out))
16181        }
16182        ProcedureValue::Array(values) => {
16183            let mut out = Vec::with_capacity(values.len());
16184            for value in values {
16185                out.push(eval_return_output(value, args, outputs)?);
16186            }
16187            Ok(ProcedureCallOutput::Array(out))
16188        }
16189    }
16190}
16191
16192fn expect_i64(value: crate::Value) -> Result<i64> {
16193    match value {
16194        crate::Value::Int64(value) => Ok(value),
16195        _ => Err(MongrelError::InvalidArgument(
16196            "procedure value must be Int64".into(),
16197        )),
16198    }
16199}
16200
16201fn expect_f64(value: crate::Value) -> Result<f64> {
16202    match value {
16203        crate::Value::Float64(value) => Ok(value),
16204        _ => Err(MongrelError::InvalidArgument(
16205            "procedure value must be Float64".into(),
16206        )),
16207    }
16208}
16209
16210fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
16211    match value {
16212        crate::Value::Bytes(value) => Ok(value),
16213        _ => Err(MongrelError::InvalidArgument(
16214            "procedure value must be Bytes".into(),
16215        )),
16216    }
16217}
16218
16219fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
16220    if schema.columns.iter().any(|c| c.id == column_id) {
16221        Ok(())
16222    } else {
16223        Err(MongrelError::InvalidArgument(format!(
16224            "unknown column id {column_id}"
16225        )))
16226    }
16227}
16228
16229fn trigger_matches_event(
16230    trigger: &StoredTrigger,
16231    event: &WriteEvent,
16232    cat: &Catalog,
16233) -> Result<bool> {
16234    if trigger.event != event.kind {
16235        return Ok(false);
16236    }
16237    let TriggerTarget::Table(target) = &trigger.target else {
16238        return Ok(false);
16239    };
16240    if target != &event.table {
16241        return Ok(false);
16242    }
16243    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
16244        let schema = &cat
16245            .live(target)
16246            .ok_or_else(|| {
16247                MongrelError::InvalidArgument(format!(
16248                    "trigger {:?} references unknown table {target:?}",
16249                    trigger.name
16250                ))
16251            })?
16252            .schema;
16253        let mut watched = Vec::with_capacity(trigger.update_of.len());
16254        for name in &trigger.update_of {
16255            let col = schema.column(name).ok_or_else(|| {
16256                MongrelError::InvalidArgument(format!(
16257                    "trigger {:?} references unknown UPDATE OF column {name:?}",
16258                    trigger.name
16259                ))
16260            })?;
16261            watched.push(col.id);
16262        }
16263        if !event
16264            .changed_columns
16265            .iter()
16266            .any(|column_id| watched.contains(column_id))
16267        {
16268            return Ok(false);
16269        }
16270    }
16271    Ok(true)
16272}
16273
16274fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
16275    let mut ids = std::collections::BTreeSet::new();
16276    if let Some(old) = old {
16277        ids.extend(old.columns.keys().copied());
16278    }
16279    if let Some(new) = new {
16280        ids.extend(new.columns.keys().copied());
16281    }
16282    ids.into_iter()
16283        .filter(|id| {
16284            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
16285        })
16286        .collect()
16287}
16288
16289fn eval_trigger_cells(
16290    cells: &[crate::trigger::TriggerCell],
16291    event: &WriteEvent,
16292    selected: Option<&TriggerRowImage>,
16293) -> Result<Vec<(u16, Value)>> {
16294    cells
16295        .iter()
16296        .map(|cell| {
16297            Ok((
16298                cell.column_id,
16299                eval_trigger_value(&cell.value, event, selected)?,
16300            ))
16301        })
16302        .collect()
16303}
16304
16305fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
16306    match expr {
16307        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
16308            Value::Bool(value) => Ok(value),
16309            Value::Null => Ok(false),
16310            other => Err(MongrelError::InvalidArgument(format!(
16311                "trigger WHEN value must be boolean, got {other:?}"
16312            ))),
16313        },
16314        TriggerExpr::Eq { left, right } => Ok(values_equal(
16315            &eval_trigger_value(left, event, None)?,
16316            &eval_trigger_value(right, event, None)?,
16317        )),
16318        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
16319            &eval_trigger_value(left, event, None)?,
16320            &eval_trigger_value(right, event, None)?,
16321        )),
16322        TriggerExpr::Lt { left, right } => match value_order(
16323            &eval_trigger_value(left, event, None)?,
16324            &eval_trigger_value(right, event, None)?,
16325        ) {
16326            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
16327            None => Ok(false),
16328        },
16329        TriggerExpr::Lte { left, right } => match value_order(
16330            &eval_trigger_value(left, event, None)?,
16331            &eval_trigger_value(right, event, None)?,
16332        ) {
16333            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
16334            None => Ok(false),
16335        },
16336        TriggerExpr::Gt { left, right } => match value_order(
16337            &eval_trigger_value(left, event, None)?,
16338            &eval_trigger_value(right, event, None)?,
16339        ) {
16340            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
16341            None => Ok(false),
16342        },
16343        TriggerExpr::Gte { left, right } => match value_order(
16344            &eval_trigger_value(left, event, None)?,
16345            &eval_trigger_value(right, event, None)?,
16346        ) {
16347            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
16348            None => Ok(false),
16349        },
16350        TriggerExpr::IsNull(value) => Ok(matches!(
16351            eval_trigger_value(value, event, None)?,
16352            Value::Null
16353        )),
16354        TriggerExpr::IsNotNull(value) => Ok(!matches!(
16355            eval_trigger_value(value, event, None)?,
16356            Value::Null
16357        )),
16358        TriggerExpr::And { left, right } => {
16359            if !eval_trigger_expr(left, event)? {
16360                Ok(false)
16361            } else {
16362                Ok(eval_trigger_expr(right, event)?)
16363            }
16364        }
16365        TriggerExpr::Or { left, right } => {
16366            if eval_trigger_expr(left, event)? {
16367                Ok(true)
16368            } else {
16369                Ok(eval_trigger_expr(right, event)?)
16370            }
16371        }
16372        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
16373    }
16374}
16375
16376fn eval_trigger_condition(
16377    condition: &TriggerCondition,
16378    event: &WriteEvent,
16379    selected: &TriggerRowImage,
16380    schema: &Schema,
16381) -> Result<bool> {
16382    match condition {
16383        TriggerCondition::Pk { value } => {
16384            let pk = schema.primary_key().ok_or_else(|| {
16385                MongrelError::InvalidArgument(
16386                    "trigger condition Pk references a table without a primary key".into(),
16387                )
16388            })?;
16389            let lhs = eval_trigger_value(value, event, Some(selected))?;
16390            Ok(values_equal(
16391                &lhs,
16392                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
16393            ))
16394        }
16395        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
16396            selected.columns.get(column_id).unwrap_or(&Value::Null),
16397            &eval_trigger_value(value, event, Some(selected))?,
16398        )),
16399        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
16400            selected.columns.get(column_id).unwrap_or(&Value::Null),
16401            &eval_trigger_value(value, event, Some(selected))?,
16402        )),
16403        TriggerCondition::Lt { column_id, value } => match value_order(
16404            selected.columns.get(column_id).unwrap_or(&Value::Null),
16405            &eval_trigger_value(value, event, Some(selected))?,
16406        ) {
16407            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
16408            None => Ok(false),
16409        },
16410        TriggerCondition::Lte { column_id, value } => match value_order(
16411            selected.columns.get(column_id).unwrap_or(&Value::Null),
16412            &eval_trigger_value(value, event, Some(selected))?,
16413        ) {
16414            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
16415            None => Ok(false),
16416        },
16417        TriggerCondition::Gt { column_id, value } => match value_order(
16418            selected.columns.get(column_id).unwrap_or(&Value::Null),
16419            &eval_trigger_value(value, event, Some(selected))?,
16420        ) {
16421            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
16422            None => Ok(false),
16423        },
16424        TriggerCondition::Gte { column_id, value } => match value_order(
16425            selected.columns.get(column_id).unwrap_or(&Value::Null),
16426            &eval_trigger_value(value, event, Some(selected))?,
16427        ) {
16428            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
16429            None => Ok(false),
16430        },
16431        TriggerCondition::IsNull { column_id } => Ok(matches!(
16432            selected.columns.get(column_id),
16433            None | Some(Value::Null)
16434        )),
16435        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
16436            selected.columns.get(column_id),
16437            None | Some(Value::Null)
16438        )),
16439        TriggerCondition::And { left, right } => {
16440            if !eval_trigger_condition(left, event, selected, schema)? {
16441                Ok(false)
16442            } else {
16443                Ok(eval_trigger_condition(right, event, selected, schema)?)
16444            }
16445        }
16446        TriggerCondition::Or { left, right } => {
16447            if eval_trigger_condition(left, event, selected, schema)? {
16448                Ok(true)
16449            } else {
16450                Ok(eval_trigger_condition(right, event, selected, schema)?)
16451            }
16452        }
16453        TriggerCondition::Not(condition) => {
16454            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
16455        }
16456    }
16457}
16458
16459fn eval_trigger_value(
16460    value: &TriggerValue,
16461    event: &WriteEvent,
16462    selected: Option<&TriggerRowImage>,
16463) -> Result<Value> {
16464    match value {
16465        TriggerValue::Literal(value) => Ok(value.clone()),
16466        TriggerValue::NewColumn(column_id) => event
16467            .new
16468            .as_ref()
16469            .and_then(|row| row.columns.get(column_id))
16470            .cloned()
16471            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
16472        TriggerValue::OldColumn(column_id) => event
16473            .old
16474            .as_ref()
16475            .and_then(|row| row.columns.get(column_id))
16476            .cloned()
16477            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
16478        TriggerValue::SelectedColumn(column_id) => selected
16479            .and_then(|row| row.columns.get(column_id))
16480            .cloned()
16481            .ok_or_else(|| {
16482                MongrelError::InvalidArgument("SELECTED column is not available".into())
16483            }),
16484    }
16485}
16486
16487fn values_equal(left: &Value, right: &Value) -> bool {
16488    match (left, right) {
16489        (Value::Null, Value::Null) => true,
16490        (Value::Bool(a), Value::Bool(b)) => a == b,
16491        (Value::Int64(a), Value::Int64(b)) => a == b,
16492        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
16493        (Value::Bytes(a), Value::Bytes(b)) => a == b,
16494        (Value::Embedding(a), Value::Embedding(b)) => {
16495            a.len() == b.len()
16496                && a.iter()
16497                    .zip(b.iter())
16498                    .all(|(a, b)| a.to_bits() == b.to_bits())
16499        }
16500        _ => false,
16501    }
16502}
16503
16504fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
16505    match (left, right) {
16506        (Value::Null, _) | (_, Value::Null) => None,
16507        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
16508        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
16509        // Cross-type Int64/Float64 comparison coerces the integer to f64.
16510        // This matches the spec but can lose precision for i64 values above 2^53.
16511        (Value::Int64(a), Value::Float64(b)) => {
16512            let af = *a as f64;
16513            Some(af.total_cmp(b))
16514        }
16515        // Cross-type Int64/Float64 comparison coerces the integer to f64.
16516        // This matches the spec but can lose precision for i64 values above 2^53.
16517        (Value::Float64(a), Value::Int64(b)) => {
16518            let bf = *b as f64;
16519            Some(a.total_cmp(&bf))
16520        }
16521        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
16522        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
16523        (Value::Embedding(_), Value::Embedding(_)) => None,
16524        _ => None,
16525    }
16526}
16527
16528fn trigger_message(value: Value) -> String {
16529    match value {
16530        Value::Null => "NULL".into(),
16531        Value::Bool(value) => value.to_string(),
16532        Value::Int64(value) => value.to_string(),
16533        Value::Float64(value) => value.to_string(),
16534        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
16535        Value::Embedding(value) => format!("{value:?}"),
16536        Value::Decimal(value) => value.to_string(),
16537        Value::Interval {
16538            months,
16539            days,
16540            nanos,
16541        } => format!("{months}m {days}d {nanos}ns"),
16542        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
16543        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
16544    }
16545}
16546
16547fn validate_trigger_step<'a>(
16548    step: &TriggerStep,
16549    cat: &'a Catalog,
16550    target_schema: &Schema,
16551    event: TriggerEvent,
16552    select_schemas: &mut HashMap<String, &'a Schema>,
16553) -> Result<()> {
16554    match step {
16555        TriggerStep::SetNew { cells } => {
16556            if event == TriggerEvent::Delete {
16557                return Err(MongrelError::InvalidArgument(
16558                    "SetNew trigger step is not valid for DELETE triggers".into(),
16559                ));
16560            }
16561            for cell in cells {
16562                validate_column_id(cell.column_id, target_schema)?;
16563                validate_trigger_value(&cell.value, target_schema, event)?;
16564            }
16565        }
16566        TriggerStep::Insert { table, cells } => {
16567            let schema = trigger_write_schema(cat, table, "insert")?;
16568            for cell in cells {
16569                validate_column_id(cell.column_id, schema)?;
16570                validate_trigger_value(&cell.value, target_schema, event)?;
16571            }
16572        }
16573        TriggerStep::UpdateByPk { table, pk, cells } => {
16574            let schema = trigger_write_schema(cat, table, "update")?;
16575            if schema.primary_key().is_none() {
16576                return Err(MongrelError::InvalidArgument(format!(
16577                    "trigger update_by_pk references table {table:?} without a primary key"
16578                )));
16579            }
16580            validate_trigger_value(pk, target_schema, event)?;
16581            for cell in cells {
16582                validate_column_id(cell.column_id, schema)?;
16583                validate_trigger_value(&cell.value, target_schema, event)?;
16584            }
16585        }
16586        TriggerStep::DeleteByPk { table, pk } => {
16587            let schema = trigger_write_schema(cat, table, "delete")?;
16588            if schema.primary_key().is_none() {
16589                return Err(MongrelError::InvalidArgument(format!(
16590                    "trigger delete_by_pk references table {table:?} without a primary key"
16591                )));
16592            }
16593            validate_trigger_value(pk, target_schema, event)?;
16594        }
16595        TriggerStep::Select {
16596            id,
16597            table,
16598            conditions,
16599        } => {
16600            let schema = trigger_read_schema(cat, table)?;
16601            for condition in conditions {
16602                validate_trigger_condition(condition, schema, target_schema, event)?;
16603            }
16604            if select_schemas.contains_key(id) {
16605                return Err(MongrelError::InvalidArgument(format!(
16606                    "duplicate select id {id:?} in trigger program"
16607                )));
16608            }
16609            select_schemas.insert(id.clone(), schema);
16610        }
16611        TriggerStep::Foreach { id, steps } => {
16612            if !select_schemas.contains_key(id) {
16613                return Err(MongrelError::InvalidArgument(format!(
16614                    "foreach references unknown select id {id:?}"
16615                )));
16616            }
16617            let mut inner_select_schemas = select_schemas.clone();
16618            for step in steps {
16619                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
16620            }
16621        }
16622        TriggerStep::DeleteWhere { table, conditions } => {
16623            let schema = trigger_write_schema(cat, table, "delete")?;
16624            for condition in conditions {
16625                validate_trigger_condition(condition, schema, target_schema, event)?;
16626            }
16627        }
16628        TriggerStep::UpdateWhere {
16629            table,
16630            conditions,
16631            cells,
16632        } => {
16633            let schema = trigger_write_schema(cat, table, "update")?;
16634            for condition in conditions {
16635                validate_trigger_condition(condition, schema, target_schema, event)?;
16636            }
16637            for cell in cells {
16638                validate_column_id(cell.column_id, schema)?;
16639                validate_trigger_value(&cell.value, target_schema, event)?;
16640            }
16641        }
16642        TriggerStep::Raise { message, .. } => {
16643            validate_trigger_value(message, target_schema, event)?
16644        }
16645    }
16646    Ok(())
16647}
16648
16649fn trigger_validation_error(error: MongrelError) -> MongrelError {
16650    match error {
16651        MongrelError::TriggerValidation(_) => error,
16652        MongrelError::InvalidArgument(message)
16653        | MongrelError::Conflict(message)
16654        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
16655        error => error,
16656    }
16657}
16658
16659fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
16660    if let Some(entry) = cat.live(table) {
16661        return Ok(&entry.schema);
16662    }
16663    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
16664        let allowed = match op {
16665            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
16666            "update" | "delete" => entry.capabilities.writable,
16667            _ => false,
16668        };
16669        if !allowed {
16670            return Err(MongrelError::InvalidArgument(format!(
16671                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
16672                entry.module
16673            )));
16674        }
16675        if !entry.capabilities.transaction_safe {
16676            return Err(MongrelError::InvalidArgument(format!(
16677                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
16678                entry.module
16679            )));
16680        }
16681        return Ok(&entry.declared_schema);
16682    }
16683    Err(MongrelError::InvalidArgument(format!(
16684        "trigger references unknown table {table:?}"
16685    )))
16686}
16687
16688fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
16689    if let Some(entry) = cat.live(table) {
16690        return Ok(&entry.schema);
16691    }
16692    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
16693        if entry.capabilities.trigger_safe {
16694            return Ok(&entry.declared_schema);
16695        }
16696        return Err(MongrelError::InvalidArgument(format!(
16697            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
16698            entry.module
16699        )));
16700    }
16701    Err(MongrelError::InvalidArgument(format!(
16702        "trigger references unknown table {table:?}"
16703    )))
16704}
16705
16706fn validate_trigger_condition(
16707    condition: &TriggerCondition,
16708    schema: &Schema,
16709    target_schema: &Schema,
16710    event: TriggerEvent,
16711) -> Result<()> {
16712    match condition {
16713        TriggerCondition::Pk { value } => {
16714            if schema.primary_key().is_none() {
16715                return Err(MongrelError::InvalidArgument(
16716                    "trigger condition Pk references a table without a primary key".into(),
16717                ));
16718            }
16719            validate_trigger_value(value, target_schema, event)
16720        }
16721        TriggerCondition::Eq { column_id, value }
16722        | TriggerCondition::NotEq { column_id, value }
16723        | TriggerCondition::Lt { column_id, value }
16724        | TriggerCondition::Lte { column_id, value }
16725        | TriggerCondition::Gt { column_id, value }
16726        | TriggerCondition::Gte { column_id, value } => {
16727            validate_column_id(*column_id, schema)?;
16728            validate_trigger_value(value, target_schema, event)
16729        }
16730        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
16731            validate_column_id(*column_id, schema)
16732        }
16733        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
16734            validate_trigger_condition(left, schema, target_schema, event)?;
16735            validate_trigger_condition(right, schema, target_schema, event)
16736        }
16737        TriggerCondition::Not(condition) => {
16738            validate_trigger_condition(condition, schema, target_schema, event)
16739        }
16740    }
16741}
16742
16743fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
16744    match expr {
16745        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
16746            validate_trigger_value(value, schema, event)
16747        }
16748        TriggerExpr::Eq { left, right }
16749        | TriggerExpr::NotEq { left, right }
16750        | TriggerExpr::Lt { left, right }
16751        | TriggerExpr::Lte { left, right }
16752        | TriggerExpr::Gt { left, right }
16753        | TriggerExpr::Gte { left, right } => {
16754            validate_trigger_value(left, schema, event)?;
16755            validate_trigger_value(right, schema, event)
16756        }
16757        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
16758            validate_trigger_expr(left, schema, event)?;
16759            validate_trigger_expr(right, schema, event)
16760        }
16761        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
16762    }
16763}
16764
16765fn validate_trigger_value(
16766    value: &TriggerValue,
16767    schema: &Schema,
16768    event: TriggerEvent,
16769) -> Result<()> {
16770    match value {
16771        TriggerValue::Literal(_) => Ok(()),
16772        TriggerValue::NewColumn(id) => {
16773            if event == TriggerEvent::Delete {
16774                return Err(MongrelError::InvalidArgument(
16775                    "DELETE triggers cannot reference NEW".into(),
16776                ));
16777            }
16778            validate_column_id(*id, schema)
16779        }
16780        TriggerValue::OldColumn(id) => {
16781            if event == TriggerEvent::Insert {
16782                return Err(MongrelError::InvalidArgument(
16783                    "INSERT triggers cannot reference OLD".into(),
16784                ));
16785            }
16786            validate_column_id(*id, schema)
16787        }
16788        // SELECTED column references are only meaningful inside a foreach loop.
16789        // Strict loop-scope validation is deferred to runtime; the executor raises
16790        // an error if a selected row is not available.
16791        TriggerValue::SelectedColumn(_) => Ok(()),
16792    }
16793}
16794
16795/// Bound on the retained tail of the per-open commit-timestamp ledger
16796/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
16797/// needs recent epochs, so the map keeps the newest commits and drops the
16798/// rest (a miss falls back to a fresh-begin HLC at the caller).
16799const COMMIT_TS_LEDGER_CAP: usize = 10_000;
16800
16801/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
16802/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
16803/// and the `Op::CommitTimestamp` record written ahead of it carries the
16804/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
16805/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
16806/// micros only (same constraint [`crate::commit_log`] documents for replayed
16807/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
16808fn commit_ts_ledger_from_recovery(
16809    records: &[crate::wal::Record],
16810) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
16811    use crate::wal::Op;
16812    let mut commits = HashMap::new();
16813    let mut timestamps = HashMap::new();
16814    for record in records {
16815        match &record.op {
16816            Op::TxnCommit { epoch, .. } => {
16817                commits.insert(record.txn_id, *epoch);
16818            }
16819            Op::CommitTimestamp { unix_nanos } => {
16820                timestamps.insert(record.txn_id, *unix_nanos);
16821            }
16822            _ => {}
16823        }
16824    }
16825    let mut ledger = std::collections::BTreeMap::new();
16826    for (txn_id, epoch) in commits {
16827        let Some(unix_nanos) = timestamps.get(&txn_id) else {
16828            continue;
16829        };
16830        ledger.insert(
16831            epoch,
16832            mongreldb_types::hlc::HlcTimestamp {
16833                physical_micros: unix_nanos / 1_000,
16834                logical: 0,
16835                node_tiebreaker: 0,
16836            },
16837        );
16838    }
16839    while ledger.len() > COMMIT_TS_LEDGER_CAP {
16840        ledger.pop_first();
16841    }
16842    ledger
16843}
16844
16845/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
16846/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
16847/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
16848/// catalog. This pass closes that window by reconstructing missing entries
16849/// (and marking committed drops) before tables are mounted.
16850fn recover_ddl_from_wal(
16851    root: &Path,
16852    durable_root: Option<&crate::durable_file::DurableRoot>,
16853    target_catalog: &mut Catalog,
16854    meta_dek: Option<&[u8; META_DEK_LEN]>,
16855    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
16856    apply: bool,
16857    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
16858) -> Result<()> {
16859    use crate::wal::SharedWal;
16860    let records = match durable_root {
16861        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
16862        None => SharedWal::replay_with_dek(root, wal_dek)?,
16863    };
16864    recover_ddl_from_records(
16865        root,
16866        durable_root,
16867        target_catalog,
16868        meta_dek,
16869        apply,
16870        table_roots,
16871        &records,
16872    )
16873}
16874
16875fn recover_ddl_from_records(
16876    root: &Path,
16877    durable_root: Option<&crate::durable_file::DurableRoot>,
16878    target_catalog: &mut Catalog,
16879    meta_dek: Option<&[u8; META_DEK_LEN]>,
16880    apply: bool,
16881    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
16882    records: &[crate::wal::Record],
16883) -> Result<()> {
16884    use crate::wal::{DdlOp, Op};
16885
16886    let original_catalog = target_catalog.clone();
16887    let mut recovered_catalog = original_catalog.clone();
16888    let cat = &mut recovered_catalog;
16889    let mut created_table_ids = HashSet::<u64>::new();
16890    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
16891
16892    let mut committed: HashMap<u64, u64> = HashMap::new();
16893    for r in records {
16894        if let Op::TxnCommit { epoch: ce, .. } = r.op {
16895            committed.insert(r.txn_id, ce);
16896        }
16897    }
16898    let catalog_snapshot_txns = records
16899        .iter()
16900        .filter_map(|record| {
16901            (committed.contains_key(&record.txn_id)
16902                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
16903            .then_some(record.txn_id)
16904        })
16905        .collect::<HashSet<_>>();
16906
16907    let mut changed = false;
16908    let mut applied_catalog_epoch = cat.db_epoch;
16909    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
16910    for r in records.iter().cloned() {
16911        let Some(&ce) = committed.get(&r.txn_id) else {
16912            continue;
16913        };
16914        let txn_id = r.txn_id;
16915        match r.op {
16916            Op::Ddl(DdlOp::CreateTable {
16917                table_id,
16918                ref name,
16919                ref schema_json,
16920            }) => {
16921                if cat.tables.iter().any(|t| t.table_id == table_id) {
16922                    continue;
16923                }
16924                let schema = DdlOp::decode_schema(schema_json)?;
16925                validate_recovered_schema(&schema)?;
16926                created_table_ids.insert(table_id);
16927                cat.tables.push(CatalogEntry {
16928                    table_id,
16929                    name: name.clone(),
16930                    schema,
16931                    state: TableState::Live,
16932                    created_epoch: ce,
16933                });
16934                cat.next_table_id =
16935                    cat.next_table_id
16936                        .max(table_id.checked_add(1).ok_or_else(|| {
16937                            MongrelError::Full("table id namespace exhausted".into())
16938                        })?);
16939                changed = true;
16940            }
16941            Op::Ddl(DdlOp::CreateBuildingTable {
16942                table_id,
16943                ref build_name,
16944                ref intended_name,
16945                ref query_id,
16946                created_at_unix_nanos,
16947                ref schema_json,
16948            }) => {
16949                if cat.tables.iter().any(|table| table.table_id == table_id) {
16950                    continue;
16951                }
16952                let schema = DdlOp::decode_schema(schema_json)?;
16953                validate_recovered_schema(&schema)?;
16954                created_table_ids.insert(table_id);
16955                cat.tables.push(CatalogEntry {
16956                    table_id,
16957                    name: build_name.clone(),
16958                    schema,
16959                    state: TableState::Building {
16960                        intended_name: intended_name.clone(),
16961                        query_id: query_id.clone(),
16962                        created_at_unix_nanos,
16963                        replaces_table_id: None,
16964                    },
16965                    created_epoch: ce,
16966                });
16967                cat.next_table_id =
16968                    cat.next_table_id
16969                        .max(table_id.checked_add(1).ok_or_else(|| {
16970                            MongrelError::Full("table id namespace exhausted".into())
16971                        })?);
16972                changed = true;
16973            }
16974            Op::Ddl(DdlOp::CreateRebuildingTable {
16975                table_id,
16976                ref build_name,
16977                ref intended_name,
16978                ref query_id,
16979                created_at_unix_nanos,
16980                replaces_table_id,
16981                ref schema_json,
16982            }) => {
16983                if cat.tables.iter().any(|table| table.table_id == table_id) {
16984                    continue;
16985                }
16986                let schema = DdlOp::decode_schema(schema_json)?;
16987                validate_recovered_schema(&schema)?;
16988                created_table_ids.insert(table_id);
16989                cat.tables.push(CatalogEntry {
16990                    table_id,
16991                    name: build_name.clone(),
16992                    schema,
16993                    state: TableState::Building {
16994                        intended_name: intended_name.clone(),
16995                        query_id: query_id.clone(),
16996                        created_at_unix_nanos,
16997                        replaces_table_id: Some(replaces_table_id),
16998                    },
16999                    created_epoch: ce,
17000                });
17001                cat.next_table_id =
17002                    cat.next_table_id
17003                        .max(table_id.checked_add(1).ok_or_else(|| {
17004                            MongrelError::Full("table id namespace exhausted".into())
17005                        })?);
17006                changed = true;
17007            }
17008            Op::Ddl(DdlOp::DropTable { table_id }) => {
17009                let mut dropped_name = None;
17010                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
17011                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
17012                        dropped_name = Some(entry.name.clone());
17013                        entry.state = TableState::Dropped { at_epoch: ce };
17014                        changed = true;
17015                    }
17016                }
17017                if let Some(name) = dropped_name {
17018                    let before = cat.materialized_views.len();
17019                    cat.materialized_views
17020                        .retain(|definition| definition.name != name);
17021                    changed |= before != cat.materialized_views.len();
17022                    cat.security.rls_tables.retain(|table| table != &name);
17023                    cat.security.policies.retain(|policy| policy.table != name);
17024                    cat.security.masks.retain(|mask| mask.table != name);
17025                    for role in &mut cat.roles {
17026                        role.permissions
17027                            .retain(|permission| permission_table(permission) != Some(&name));
17028                    }
17029                    if !catalog_snapshot_txns.contains(&txn_id) {
17030                        advance_security_version(cat)?;
17031                    }
17032                }
17033            }
17034            Op::Ddl(DdlOp::PublishBuildingTable {
17035                table_id,
17036                ref new_name,
17037            }) => {
17038                if let Some(entry) = cat
17039                    .tables
17040                    .iter_mut()
17041                    .find(|table| table.table_id == table_id)
17042                {
17043                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
17044                        entry.name = new_name.clone();
17045                        entry.state = TableState::Live;
17046                        changed = true;
17047                    }
17048                }
17049            }
17050            Op::Ddl(DdlOp::ReplaceBuildingTable {
17051                table_id,
17052                replaced_table_id,
17053                ref new_name,
17054            }) => {
17055                changed |=
17056                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
17057            }
17058            Op::Ddl(DdlOp::RenameTable {
17059                table_id,
17060                ref new_name,
17061            }) => {
17062                let mut old_name = None;
17063                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
17064                    if entry.name != *new_name {
17065                        old_name = Some(entry.name.clone());
17066                        entry.name = new_name.clone();
17067                        changed = true;
17068                    }
17069                }
17070                if let Some(old_name) = old_name {
17071                    if let Some(definition) = cat
17072                        .materialized_views
17073                        .iter_mut()
17074                        .find(|definition| definition.name == old_name)
17075                    {
17076                        definition.name = new_name.clone();
17077                    }
17078                    for table in &mut cat.security.rls_tables {
17079                        if *table == old_name {
17080                            *table = new_name.clone();
17081                        }
17082                    }
17083                    for policy in &mut cat.security.policies {
17084                        if policy.table == old_name {
17085                            policy.table = new_name.clone();
17086                        }
17087                    }
17088                    for mask in &mut cat.security.masks {
17089                        if mask.table == old_name {
17090                            mask.table = new_name.clone();
17091                        }
17092                    }
17093                    for role in &mut cat.roles {
17094                        for permission in &mut role.permissions {
17095                            rename_permission_table(permission, &old_name, new_name);
17096                        }
17097                    }
17098                    if !catalog_snapshot_txns.contains(&txn_id) {
17099                        advance_security_version(cat)?;
17100                    }
17101                }
17102                // If the entry is absent, its CreateTable was already
17103                // checkpointed carrying the post-rename name, so there is
17104                // nothing to apply — a no-op, not an error.
17105            }
17106            Op::Ddl(DdlOp::AlterTable {
17107                table_id,
17108                ref column_json,
17109            }) => {
17110                let column = DdlOp::decode_column(column_json)?;
17111                let mut renamed = None;
17112                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
17113                    renamed = entry
17114                        .schema
17115                        .columns
17116                        .iter()
17117                        .find(|existing| existing.id == column.id && existing.name != column.name)
17118                        .map(|existing| {
17119                            (
17120                                entry.name.clone(),
17121                                existing.name.clone(),
17122                                column.name.clone(),
17123                            )
17124                        });
17125                    if apply_recovered_column_def(&mut entry.schema, column)? {
17126                        validate_recovered_schema(&entry.schema)?;
17127                        changed = true;
17128                    }
17129                }
17130                if let Some((table, old_name, new_name)) = renamed {
17131                    for role in &mut cat.roles {
17132                        for permission in &mut role.permissions {
17133                            rename_permission_column(permission, &table, &old_name, &new_name);
17134                        }
17135                    }
17136                    if !catalog_snapshot_txns.contains(&txn_id) {
17137                        advance_security_version(cat)?;
17138                    }
17139                }
17140            }
17141            Op::Ddl(DdlOp::SetTtl {
17142                table_id,
17143                ref policy_json,
17144            }) => {
17145                let policy = DdlOp::decode_ttl(policy_json)?;
17146                let entry = cat
17147                    .tables
17148                    .iter()
17149                    .find(|entry| entry.table_id == table_id)
17150                    .ok_or_else(|| {
17151                        MongrelError::Schema(format!(
17152                            "recovered TTL references unknown table id {table_id}"
17153                        ))
17154                    })?;
17155                if let Some(policy) = policy {
17156                    let valid = entry
17157                        .schema
17158                        .columns
17159                        .iter()
17160                        .find(|column| column.id == policy.column_id)
17161                        .is_some_and(|column| {
17162                            column.ty == TypeId::TimestampNanos
17163                                && policy.duration_nanos > 0
17164                                && policy.duration_nanos <= i64::MAX as u64
17165                        });
17166                    if !valid {
17167                        return Err(MongrelError::Schema(format!(
17168                            "invalid recovered TTL policy for table id {table_id}"
17169                        )));
17170                    }
17171                }
17172                ttl_updates.insert(table_id, (policy, ce));
17173            }
17174            Op::Ddl(DdlOp::SetMaterializedView {
17175                ref name,
17176                ref definition_json,
17177            }) => {
17178                let definition = DdlOp::decode_materialized_view(definition_json)?;
17179                if definition.name != *name {
17180                    return Err(MongrelError::Schema(format!(
17181                        "materialized view WAL name mismatch: {name:?}"
17182                    )));
17183                }
17184                if cat.live(name).is_some() {
17185                    if let Some(existing) = cat
17186                        .materialized_views
17187                        .iter_mut()
17188                        .find(|existing| existing.name == *name)
17189                    {
17190                        if *existing != definition {
17191                            *existing = definition;
17192                            changed = true;
17193                        }
17194                    } else {
17195                        cat.materialized_views.push(definition);
17196                        changed = true;
17197                    }
17198                }
17199            }
17200            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
17201                let security = DdlOp::decode_security(security_json)?;
17202                validate_security_catalog(cat, &security)?;
17203                if cat.security != security {
17204                    cat.security = security;
17205                    if !catalog_snapshot_txns.contains(&txn_id) {
17206                        advance_security_version(cat)?;
17207                    }
17208                    changed = true;
17209                }
17210            }
17211            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
17212                let target = match key.as_str() {
17213                    "user_version" => &mut cat.user_version,
17214                    "application_id" => &mut cat.application_id,
17215                    _ => {
17216                        return Err(MongrelError::InvalidArgument(format!(
17217                            "unsupported recovered SQL pragma {key:?}"
17218                        )))
17219                    }
17220                };
17221                if *target != Some(value) {
17222                    *target = Some(value);
17223                    cat.db_epoch = cat.db_epoch.max(ce);
17224                    changed = true;
17225                }
17226            }
17227            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
17228                if ce <= applied_catalog_epoch {
17229                    continue;
17230                }
17231                let snapshot = DdlOp::decode_catalog(catalog_json)?;
17232                if snapshot.db_epoch != ce {
17233                    return Err(MongrelError::Schema(format!(
17234                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
17235                        snapshot.db_epoch
17236                    )));
17237                }
17238                validate_recovered_catalog(&snapshot)?;
17239                validate_catalog_transition(cat, &snapshot)?;
17240                *cat = snapshot;
17241                applied_catalog_epoch = ce;
17242                changed = true;
17243            }
17244            _ => {}
17245        }
17246    }
17247
17248    if cat.db_epoch < max_committed_epoch {
17249        cat.db_epoch = max_committed_epoch;
17250        changed = true;
17251    }
17252    changed |= repair_catalog_allocator_counters(cat)?;
17253
17254    validate_recovered_catalog(cat)?;
17255    let storage_reconciliation = validate_recovered_storage_plan(
17256        root,
17257        durable_root,
17258        cat,
17259        &created_table_ids,
17260        &ttl_updates,
17261        meta_dek,
17262    )?;
17263
17264    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
17265    if apply && (changed || needs_storage_apply) {
17266        for table_id in storage_reconciliation {
17267            let entry = cat
17268                .tables
17269                .iter()
17270                .find(|entry| entry.table_id == table_id)
17271                .ok_or_else(|| MongrelError::CorruptWal {
17272                    offset: table_id,
17273                    reason: "recovery storage plan lost its catalog table".into(),
17274                })?;
17275            ensure_recovered_table_storage(
17276                table_roots
17277                    .and_then(|roots| roots.get(&table_id))
17278                    .map(Arc::as_ref),
17279                durable_root,
17280                &root.join(TABLES_DIR).join(table_id.to_string()),
17281                table_id,
17282                &entry.schema,
17283                meta_dek,
17284            )?;
17285        }
17286        for (table_id, (policy, ttl_epoch)) in ttl_updates {
17287            let Some(entry) = cat.tables.iter().find(|entry| {
17288                entry.table_id == table_id
17289                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
17290            }) else {
17291                continue;
17292            };
17293            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
17294            {
17295                root.try_clone()?
17296            } else if let Some(root) = durable_root {
17297                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
17298            } else {
17299                crate::durable_file::DurableRoot::open(
17300                    root.join(TABLES_DIR).join(table_id.to_string()),
17301                )?
17302            };
17303            let table_dir = table_root.io_path()?;
17304            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
17305            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
17306                manifest.ttl = policy;
17307                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
17308                manifest.schema_id = entry.schema.schema_id;
17309                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
17310            }
17311        }
17312        if changed {
17313            match durable_root {
17314                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
17315                None => catalog::write_atomic(root, cat, meta_dek)?,
17316            }
17317        }
17318    }
17319    *target_catalog = recovered_catalog;
17320    Ok(())
17321}
17322
17323fn ensure_recovered_table_storage(
17324    pinned_table: Option<&crate::durable_file::DurableRoot>,
17325    durable_root: Option<&crate::durable_file::DurableRoot>,
17326    fallback_table_dir: &Path,
17327    table_id: u64,
17328    schema: &Schema,
17329    meta_dek: Option<&[u8; META_DEK_LEN]>,
17330) -> Result<()> {
17331    let table_root = if let Some(root) = pinned_table {
17332        root.try_clone()?
17333    } else if let Some(root) = durable_root {
17334        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
17335        match root.open_directory(&relative) {
17336            Ok(table) => table,
17337            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17338                root.create_directory_all_pinned(relative)?
17339            }
17340            Err(error) => return Err(error.into()),
17341        }
17342    } else {
17343        crate::durable_file::create_directory_all(fallback_table_dir)?;
17344        crate::durable_file::DurableRoot::open(fallback_table_dir)?
17345    };
17346    let table_dir = table_root.io_path()?;
17347    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
17348        Ok(manifest) => {
17349            if manifest.table_id != table_id {
17350                return Err(MongrelError::Conflict(format!(
17351                    "recovered table directory id mismatch: expected {table_id}, found {}",
17352                    manifest.table_id
17353                )));
17354            }
17355            Some(manifest)
17356        }
17357        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
17358        Err(error) => return Err(error),
17359    };
17360
17361    table_root.create_directory_all(crate::engine::WAL_DIR)?;
17362    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
17363    crate::engine::write_schema(&table_dir, schema)?;
17364
17365    if let Some(mut manifest) = existing_manifest.take() {
17366        if manifest.schema_id != schema.schema_id {
17367            manifest.schema_id = schema.schema_id;
17368            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
17369        }
17370    } else {
17371        // The DB-wide meta DEK is also the per-table manifest meta DEK.
17372        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
17373        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
17374    }
17375    Ok(())
17376}
17377
17378fn validate_recovered_schema(schema: &Schema) -> Result<()> {
17379    schema.validate_auto_increment()?;
17380    schema.validate_defaults()?;
17381    schema.validate_ai()?;
17382    let mut column_ids = HashSet::new();
17383    let mut column_names = HashSet::new();
17384    for column in &schema.columns {
17385        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
17386            return Err(MongrelError::Schema(
17387                "recovered schema contains duplicate columns".into(),
17388            ));
17389        }
17390        match &column.ty {
17391            TypeId::Decimal128 { precision, scale }
17392                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
17393            {
17394                return Err(MongrelError::Schema(format!(
17395                    "column {:?} has invalid decimal precision or scale",
17396                    column.name
17397                )));
17398            }
17399            TypeId::Enum { variants }
17400                if variants.is_empty()
17401                    || variants.iter().any(String::is_empty)
17402                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
17403            {
17404                return Err(MongrelError::Schema(format!(
17405                    "column {:?} has invalid enum variants",
17406                    column.name
17407                )));
17408            }
17409            _ => {}
17410        }
17411    }
17412    let mut index_names = HashSet::new();
17413    for index in &schema.indexes {
17414        index.validate_options()?;
17415        if index.name.is_empty()
17416            || !index_names.insert(index.name.as_str())
17417            || schema
17418                .columns
17419                .iter()
17420                .all(|column| column.id != index.column_id)
17421        {
17422            return Err(MongrelError::Schema(format!(
17423                "recovered index {:?} references missing column {}",
17424                index.name, index.column_id
17425            )));
17426        }
17427    }
17428    let mut colocated = HashSet::new();
17429    for group in &schema.colocation {
17430        if group.is_empty()
17431            || group.iter().any(|id| !column_ids.contains(id))
17432            || group.iter().any(|id| !colocated.insert(*id))
17433        {
17434            return Err(MongrelError::Schema(
17435                "recovered schema contains invalid column co-location groups".into(),
17436            ));
17437        }
17438    }
17439
17440    let mut constraint_ids = HashSet::new();
17441    let mut constraint_names = HashSet::<String>::new();
17442    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
17443        if name.is_empty()
17444            || !constraint_ids.insert(id)
17445            || !constraint_names.insert(name.to_owned())
17446        {
17447            return Err(MongrelError::Schema(
17448                "recovered schema contains duplicate or empty constraint identities".into(),
17449            ));
17450        }
17451        Ok(())
17452    };
17453    for unique in &schema.constraints.uniques {
17454        validate_constraint_identity(unique.id, &unique.name)?;
17455        if unique.columns.is_empty()
17456            || unique.columns.iter().any(|id| !column_ids.contains(id))
17457            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
17458        {
17459            return Err(MongrelError::Schema(format!(
17460                "unique constraint {:?} has invalid columns",
17461                unique.name
17462            )));
17463        }
17464    }
17465    for foreign_key in &schema.constraints.foreign_keys {
17466        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
17467        if foreign_key.ref_table.is_empty()
17468            || foreign_key.columns.is_empty()
17469            || foreign_key.columns.len() != foreign_key.ref_columns.len()
17470            || foreign_key
17471                .columns
17472                .iter()
17473                .any(|id| !column_ids.contains(id))
17474            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
17475            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
17476                != foreign_key.ref_columns.len()
17477        {
17478            return Err(MongrelError::Schema(format!(
17479                "foreign key {:?} has invalid columns",
17480                foreign_key.name
17481            )));
17482        }
17483        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
17484            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
17485            && foreign_key.columns.iter().any(|id| {
17486                schema
17487                    .columns
17488                    .iter()
17489                    .find(|column| column.id == *id)
17490                    .is_none_or(|column| {
17491                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
17492                    })
17493            })
17494        {
17495            return Err(MongrelError::Schema(format!(
17496                "foreign key {:?} uses SET NULL on a non-nullable column",
17497                foreign_key.name
17498            )));
17499        }
17500    }
17501    for check in &schema.constraints.checks {
17502        validate_constraint_identity(check.id, &check.name)?;
17503        check.expr.validate()?;
17504        validate_check_columns(&check.expr, &column_ids)?;
17505    }
17506    Ok(())
17507}
17508
17509fn validate_check_columns(
17510    expression: &crate::constraint::CheckExpr,
17511    column_ids: &HashSet<u16>,
17512) -> Result<()> {
17513    use crate::constraint::CheckExpr;
17514    match expression {
17515        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
17516            if column_ids.contains(id) {
17517                Ok(())
17518            } else {
17519                Err(MongrelError::Schema(format!(
17520                    "check constraint references unknown column {id}"
17521                )))
17522            }
17523        }
17524        CheckExpr::Regex { col, .. } => {
17525            if column_ids.contains(col) {
17526                Ok(())
17527            } else {
17528                Err(MongrelError::Schema(format!(
17529                    "check constraint references unknown column {col}"
17530                )))
17531            }
17532        }
17533        CheckExpr::Add(left, right)
17534        | CheckExpr::Sub(left, right)
17535        | CheckExpr::Mul(left, right)
17536        | CheckExpr::Div(left, right)
17537        | CheckExpr::Mod(left, right)
17538        | CheckExpr::Eq(left, right)
17539        | CheckExpr::Ne(left, right)
17540        | CheckExpr::Lt(left, right)
17541        | CheckExpr::Le(left, right)
17542        | CheckExpr::Gt(left, right)
17543        | CheckExpr::Ge(left, right)
17544        | CheckExpr::And(left, right)
17545        | CheckExpr::Or(left, right) => {
17546            validate_check_columns(left, column_ids)?;
17547            validate_check_columns(right, column_ids)
17548        }
17549        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
17550        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
17551    }
17552}
17553
17554fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
17555    for (name, prior, candidate) in [
17556        ("db_epoch", current.db_epoch, next.db_epoch),
17557        ("next_table_id", current.next_table_id, next.next_table_id),
17558        (
17559            "next_segment_no",
17560            current.next_segment_no,
17561            next.next_segment_no,
17562        ),
17563        ("next_user_id", current.next_user_id, next.next_user_id),
17564        (
17565            "security_version",
17566            current.security_version,
17567            next.security_version,
17568        ),
17569    ] {
17570        if candidate < prior {
17571            return Err(MongrelError::Schema(format!(
17572                "catalog snapshot rolls back {name} from {prior} to {candidate}"
17573            )));
17574        }
17575    }
17576    for prior in &current.tables {
17577        let Some(candidate) = next
17578            .tables
17579            .iter()
17580            .find(|entry| entry.table_id == prior.table_id)
17581        else {
17582            return Err(MongrelError::Schema(format!(
17583                "catalog snapshot removes table identity {}",
17584                prior.table_id
17585            )));
17586        };
17587        if candidate.created_epoch != prior.created_epoch
17588            || candidate.schema.schema_id < prior.schema.schema_id
17589            || matches!(prior.state, TableState::Dropped { .. })
17590                && !matches!(candidate.state, TableState::Dropped { .. })
17591        {
17592            return Err(MongrelError::Schema(format!(
17593                "catalog snapshot rolls back table identity {}",
17594                prior.table_id
17595            )));
17596        }
17597    }
17598    for prior in &current.users {
17599        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
17600            if candidate.username != prior.username
17601                || candidate.created_epoch != prior.created_epoch
17602            {
17603                return Err(MongrelError::Schema(format!(
17604                    "catalog snapshot reuses user identity {}",
17605                    prior.id
17606                )));
17607            }
17608        }
17609    }
17610    Ok(())
17611}
17612
17613fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
17614    let mut table_ids = HashSet::new();
17615    let mut active_names = HashSet::new();
17616    let mut max_table_id = None::<u64>;
17617    for entry in &catalog.tables {
17618        if !table_ids.insert(entry.table_id) {
17619            return Err(MongrelError::Schema(format!(
17620                "catalog contains duplicate table id {}",
17621                entry.table_id
17622            )));
17623        }
17624        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
17625        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
17626            return Err(MongrelError::Schema(format!(
17627                "catalog table {} has invalid name or creation epoch",
17628                entry.table_id
17629            )));
17630        }
17631        validate_recovered_schema(&entry.schema)?;
17632        match &entry.state {
17633            TableState::Live => {
17634                if !active_names.insert(entry.name.as_str()) {
17635                    return Err(MongrelError::Schema(format!(
17636                        "catalog contains duplicate active table name {:?}",
17637                        entry.name
17638                    )));
17639                }
17640            }
17641            TableState::Dropped { at_epoch } => {
17642                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
17643                    return Err(MongrelError::Schema(format!(
17644                        "catalog table {} has invalid drop epoch {at_epoch}",
17645                        entry.table_id
17646                    )));
17647                }
17648            }
17649            TableState::Building {
17650                intended_name,
17651                query_id,
17652                replaces_table_id,
17653                ..
17654            } => {
17655                if intended_name.is_empty() || query_id.is_empty() {
17656                    return Err(MongrelError::Schema(format!(
17657                        "building table {} has empty identity fields",
17658                        entry.table_id
17659                    )));
17660                }
17661                if !active_names.insert(entry.name.as_str()) {
17662                    return Err(MongrelError::Schema(format!(
17663                        "catalog contains duplicate active/building table name {:?}",
17664                        entry.name
17665                    )));
17666                }
17667                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
17668                    return Err(MongrelError::Schema(
17669                        "building table cannot replace itself".into(),
17670                    ));
17671                }
17672            }
17673        }
17674    }
17675    if let Some(maximum) = max_table_id {
17676        let required = maximum
17677            .checked_add(1)
17678            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
17679        if catalog.next_table_id < required {
17680            return Err(MongrelError::Schema(format!(
17681                "catalog next_table_id {} precedes required {required}",
17682                catalog.next_table_id
17683            )));
17684        }
17685    }
17686    for entry in &catalog.tables {
17687        if let TableState::Building {
17688            replaces_table_id: Some(replaced),
17689            ..
17690        } = entry.state
17691        {
17692            if !table_ids.contains(&replaced) {
17693                return Err(MongrelError::Schema(format!(
17694                    "building table {} replaces unknown table {replaced}",
17695                    entry.table_id
17696                )));
17697            }
17698        }
17699    }
17700    for entry in &catalog.tables {
17701        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
17702            validate_foreign_key_targets(catalog, &entry.schema)?;
17703        }
17704    }
17705
17706    let mut external_names = HashSet::new();
17707    for entry in &catalog.external_tables {
17708        entry.validate()?;
17709        validate_recovered_schema(&entry.declared_schema)?;
17710        if !entry.declared_schema.constraints.is_empty() {
17711            return Err(MongrelError::Schema(format!(
17712                "external table {:?} cannot carry engine-enforced constraints",
17713                entry.name
17714            )));
17715        }
17716        if entry.created_epoch > catalog.db_epoch
17717            || !external_names.insert(entry.name.as_str())
17718            || active_names.contains(entry.name.as_str())
17719        {
17720            return Err(MongrelError::Schema(format!(
17721                "invalid or duplicate external table {:?}",
17722                entry.name
17723            )));
17724        }
17725    }
17726
17727    let mut procedure_names = HashSet::new();
17728    for entry in &catalog.procedures {
17729        entry.procedure.validate()?;
17730        if entry.procedure.created_epoch > entry.procedure.updated_epoch
17731            || entry.procedure.updated_epoch > catalog.db_epoch
17732            || !procedure_names.insert(entry.procedure.name.as_str())
17733        {
17734            return Err(MongrelError::Schema(format!(
17735                "invalid or duplicate procedure {:?}",
17736                entry.procedure.name
17737            )));
17738        }
17739        validate_recovered_procedure_references(catalog, &entry.procedure)?;
17740    }
17741
17742    let mut trigger_names = HashSet::new();
17743    for entry in &catalog.triggers {
17744        entry.trigger.validate()?;
17745        if entry.trigger.created_epoch > entry.trigger.updated_epoch
17746            || entry.trigger.updated_epoch > catalog.db_epoch
17747            || !trigger_names.insert(entry.trigger.name.as_str())
17748        {
17749            return Err(MongrelError::Schema(format!(
17750                "invalid or duplicate trigger {:?}",
17751                entry.trigger.name
17752            )));
17753        }
17754        validate_recovered_trigger_references(catalog, &entry.trigger)?;
17755    }
17756
17757    let mut views = HashSet::new();
17758    for view in &catalog.materialized_views {
17759        let target = catalog.live(&view.name).ok_or_else(|| {
17760            MongrelError::Schema(format!(
17761                "materialized view {:?} has no live table",
17762                view.name
17763            ))
17764        })?;
17765        if view.name.is_empty()
17766            || view.query.trim().is_empty()
17767            || view.last_refresh_epoch > catalog.db_epoch
17768            || !views.insert(view.name.as_str())
17769        {
17770            return Err(MongrelError::Schema(format!(
17771                "materialized view {:?} has no unique live table",
17772                view.name
17773            )));
17774        }
17775        if let Some(incremental) = &view.incremental {
17776            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
17777                MongrelError::Schema(format!(
17778                    "materialized view {:?} references missing source {:?}",
17779                    view.name, incremental.source_table
17780                ))
17781            })?;
17782            if source.table_id != incremental.source_table_id
17783                || source
17784                    .schema
17785                    .columns
17786                    .iter()
17787                    .all(|column| column.id != incremental.group_column)
17788            {
17789                return Err(MongrelError::Schema(format!(
17790                    "materialized view {:?} has invalid incremental source",
17791                    view.name
17792                )));
17793            }
17794            let target_ids = target
17795                .schema
17796                .columns
17797                .iter()
17798                .map(|column| column.id)
17799                .collect::<HashSet<_>>();
17800            let mut output_ids = HashSet::new();
17801            let count_outputs = incremental
17802                .outputs
17803                .iter()
17804                .filter(|output| {
17805                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
17806                })
17807                .count();
17808            if incremental.checkpoint_event_id.is_empty()
17809                || !target_ids.contains(&incremental.group_output_column)
17810                || !target_ids.contains(&incremental.count_output_column)
17811                || incremental.outputs.is_empty()
17812                || count_outputs != 1
17813                || incremental.outputs.iter().any(|output| {
17814                    !target_ids.contains(&output.output_column)
17815                        || output.output_column == incremental.group_output_column
17816                        || !output_ids.insert(output.output_column)
17817                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
17818                            && output.output_column != incremental.count_output_column
17819                        || match output.kind {
17820                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
17821                                source
17822                                    .schema
17823                                    .columns
17824                                    .iter()
17825                                    .all(|column| column.id != source_column)
17826                            }
17827                            crate::catalog::IncrementalAggregateKind::Count => false,
17828                        }
17829                })
17830            {
17831                return Err(MongrelError::Schema(format!(
17832                    "materialized view {:?} has invalid incremental outputs",
17833                    view.name
17834                )));
17835            }
17836        }
17837    }
17838
17839    validate_security_catalog(catalog, &catalog.security)?;
17840    validate_recovered_auth_catalog(catalog)?;
17841    Ok(())
17842}
17843
17844fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
17845    let mut changed = false;
17846    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
17847        let required = maximum
17848            .checked_add(1)
17849            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
17850        if catalog.next_table_id < required {
17851            catalog.next_table_id = required;
17852            changed = true;
17853        }
17854    }
17855    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
17856        let required = maximum
17857            .checked_add(1)
17858            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
17859        if catalog.next_user_id < required {
17860            catalog.next_user_id = required;
17861            changed = true;
17862        }
17863    }
17864    Ok(changed)
17865}
17866
17867fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
17868    for foreign_key in &schema.constraints.foreign_keys {
17869        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
17870            MongrelError::Schema(format!(
17871                "foreign key {:?} references unknown live table {:?}",
17872                foreign_key.name, foreign_key.ref_table
17873            ))
17874        })?;
17875        let referenced_unique = parent
17876            .schema
17877            .constraints
17878            .uniques
17879            .iter()
17880            .any(|unique| unique.columns == foreign_key.ref_columns)
17881            || foreign_key.ref_columns.len() == 1
17882                && parent
17883                    .schema
17884                    .primary_key()
17885                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
17886        if !referenced_unique {
17887            return Err(MongrelError::Schema(format!(
17888                "foreign key {:?} does not reference a unique key",
17889                foreign_key.name
17890            )));
17891        }
17892        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
17893            let local = schema.columns.iter().find(|column| column.id == *local_id);
17894            let referenced = parent
17895                .schema
17896                .columns
17897                .iter()
17898                .find(|column| column.id == *parent_id);
17899            if local
17900                .zip(referenced)
17901                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
17902            {
17903                return Err(MongrelError::Schema(format!(
17904                    "foreign key {:?} has missing or incompatible columns",
17905                    foreign_key.name
17906                )));
17907            }
17908        }
17909    }
17910    Ok(())
17911}
17912
17913fn validate_recovered_procedure_references(
17914    catalog: &Catalog,
17915    procedure: &StoredProcedure,
17916) -> Result<()> {
17917    for step in &procedure.body.steps {
17918        let Some(table_name) = step.table() else {
17919            continue;
17920        };
17921        let schema = &catalog
17922            .live(table_name)
17923            .ok_or_else(|| {
17924                MongrelError::Schema(format!(
17925                    "procedure {:?} references unknown table {table_name:?}",
17926                    procedure.name
17927                ))
17928            })?
17929            .schema;
17930        match step {
17931            ProcedureStep::NativeQuery {
17932                conditions,
17933                projection,
17934                ..
17935            } => {
17936                for condition in conditions {
17937                    validate_condition_columns(condition, schema)?;
17938                }
17939                for id in projection.iter().flatten() {
17940                    validate_column_id(*id, schema)?;
17941                }
17942            }
17943            ProcedureStep::Put { cells, .. } => {
17944                for cell in cells {
17945                    validate_column_id(cell.column_id, schema)?;
17946                }
17947            }
17948            ProcedureStep::Upsert {
17949                cells,
17950                update_cells,
17951                ..
17952            } => {
17953                for cell in cells.iter().chain(update_cells.iter().flatten()) {
17954                    validate_column_id(cell.column_id, schema)?;
17955                }
17956            }
17957            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
17958                return Err(MongrelError::Schema(format!(
17959                    "procedure {:?} deletes by primary key on table without one",
17960                    procedure.name
17961                )));
17962            }
17963            ProcedureStep::DeleteByPk { .. }
17964            | ProcedureStep::DeleteRows { .. }
17965            | ProcedureStep::SqlQuery { .. } => {}
17966        }
17967    }
17968    Ok(())
17969}
17970
17971fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
17972    let target_schema = match &trigger.target {
17973        TriggerTarget::Table(name) => catalog
17974            .live(name)
17975            .ok_or_else(|| {
17976                MongrelError::Schema(format!(
17977                    "trigger {:?} references unknown table {name:?}",
17978                    trigger.name
17979                ))
17980            })?
17981            .schema
17982            .clone(),
17983        TriggerTarget::View(_) => Schema {
17984            columns: trigger.target_columns.clone(),
17985            ..Schema::default()
17986        },
17987    };
17988    for column in &trigger.update_of {
17989        if target_schema.column(column).is_none() {
17990            return Err(MongrelError::Schema(format!(
17991                "trigger {:?} references unknown UPDATE OF column {column:?}",
17992                trigger.name
17993            )));
17994        }
17995    }
17996    if let Some(expr) = &trigger.when {
17997        validate_trigger_expr(expr, &target_schema, trigger.event)?;
17998    }
17999    let mut selects = HashMap::new();
18000    for step in &trigger.program.steps {
18001        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
18002            return Err(MongrelError::Schema(
18003                "SetNew is only valid in BEFORE triggers".into(),
18004            ));
18005        }
18006        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
18007    }
18008    Ok(())
18009}
18010
18011fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
18012    let mut role_names = HashSet::new();
18013    for role in &catalog.roles {
18014        if role.name.is_empty()
18015            || role.created_epoch > catalog.db_epoch
18016            || !role_names.insert(role.name.as_str())
18017        {
18018            return Err(MongrelError::Schema(format!(
18019                "invalid or duplicate role {:?}",
18020                role.name
18021            )));
18022        }
18023        for permission in &role.permissions {
18024            if let Some(table) = permission_table(permission) {
18025                let schema = catalog
18026                    .live(table)
18027                    .map(|entry| &entry.schema)
18028                    .or_else(|| {
18029                        catalog
18030                            .external_tables
18031                            .iter()
18032                            .find(|entry| entry.name == table)
18033                            .map(|entry| &entry.declared_schema)
18034                    })
18035                    .ok_or_else(|| {
18036                        MongrelError::Schema(format!(
18037                            "role {:?} references unknown table {table:?}",
18038                            role.name
18039                        ))
18040                    })?;
18041                let columns = match permission {
18042                    crate::auth::Permission::SelectColumns { columns, .. }
18043                    | crate::auth::Permission::InsertColumns { columns, .. }
18044                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
18045                    _ => None,
18046                };
18047                if columns.is_some_and(|columns| {
18048                    columns.is_empty()
18049                        || columns.iter().any(|column| schema.column(column).is_none())
18050                }) {
18051                    return Err(MongrelError::Schema(format!(
18052                        "role {:?} contains invalid column permissions",
18053                        role.name
18054                    )));
18055                }
18056            }
18057        }
18058    }
18059    let mut user_ids = HashSet::new();
18060    let mut usernames = HashSet::new();
18061    let mut maximum_user_id = 0;
18062    for user in &catalog.users {
18063        maximum_user_id = maximum_user_id.max(user.id);
18064        if user.id == 0
18065            || user.username.is_empty()
18066            || user.password_hash.is_empty()
18067            || user.created_epoch > catalog.db_epoch
18068            || !user_ids.insert(user.id)
18069            || !usernames.insert(user.username.as_str())
18070            || user
18071                .roles
18072                .iter()
18073                .any(|role| !role_names.contains(role.as_str()))
18074        {
18075            return Err(MongrelError::Schema(format!(
18076                "invalid or duplicate user {:?}",
18077                user.username
18078            )));
18079        }
18080    }
18081    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
18082        return Err(MongrelError::Schema(
18083            "catalog next_user_id does not advance beyond existing user ids".into(),
18084        ));
18085    }
18086    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
18087        return Err(MongrelError::Schema(
18088            "authenticated catalog has no administrator".into(),
18089        ));
18090    }
18091    Ok(())
18092}
18093
18094fn validate_recovered_storage_plan(
18095    root: &Path,
18096    durable_root: Option<&crate::durable_file::DurableRoot>,
18097    catalog: &Catalog,
18098    created_table_ids: &HashSet<u64>,
18099    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
18100    meta_dek: Option<&[u8; META_DEK_LEN]>,
18101) -> Result<Vec<u64>> {
18102    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
18103    let mut reconcile = Vec::new();
18104    for entry in &catalog.tables {
18105        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18106            continue;
18107        }
18108        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
18109        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
18110        let table_exists = match durable_root {
18111            Some(root) => match root.open_directory(&relative_dir) {
18112                Ok(_) => true,
18113                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
18114                Err(error) => return Err(error.into()),
18115            },
18116            None => table_dir.is_dir(),
18117        };
18118        if !table_exists {
18119            if created_table_ids.contains(&entry.table_id) {
18120                reconcile.push(entry.table_id);
18121                continue;
18122            }
18123            return Err(MongrelError::NotFound(format!(
18124                "catalog table {} storage is missing",
18125                entry.table_id
18126            )));
18127        }
18128        let manifest_result = match durable_root {
18129            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
18130            None => crate::manifest::read(&table_dir, meta_dek),
18131        };
18132        let manifest = match manifest_result {
18133            Ok(manifest) => manifest,
18134            Err(MongrelError::Io(error))
18135                if created_table_ids.contains(&entry.table_id)
18136                    && error.kind() == std::io::ErrorKind::NotFound =>
18137            {
18138                reconcile.push(entry.table_id);
18139                continue;
18140            }
18141            Err(error) => return Err(error),
18142        };
18143        if manifest.table_id != entry.table_id {
18144            return Err(MongrelError::Conflict(format!(
18145                "catalog table {} storage identity mismatch",
18146                entry.table_id
18147            )));
18148        }
18149        let schema_result = match durable_root {
18150            Some(root) => root
18151                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
18152                .map_err(MongrelError::from),
18153            None => crate::durable_file::open_regular_nofollow(
18154                &table_dir.join(crate::engine::SCHEMA_FILENAME),
18155            ),
18156        };
18157        let file = match schema_result {
18158            Ok(file) => file,
18159            Err(MongrelError::Io(error))
18160                if created_table_ids.contains(&entry.table_id)
18161                    && error.kind() == std::io::ErrorKind::NotFound =>
18162            {
18163                reconcile.push(entry.table_id);
18164                continue;
18165            }
18166            Err(error) => return Err(error),
18167        };
18168        let length = file.metadata()?.len();
18169        if length > MAX_SCHEMA_BYTES {
18170            return Err(MongrelError::ResourceLimitExceeded {
18171                resource: "recovered schema bytes",
18172                requested: usize::try_from(length).unwrap_or(usize::MAX),
18173                limit: MAX_SCHEMA_BYTES as usize,
18174            });
18175        }
18176        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
18177            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
18178        if manifest.schema_id != entry.schema.schema_id
18179            || crate::wal::DdlOp::encode_schema(&disk_schema)?
18180                != crate::wal::DdlOp::encode_schema(&entry.schema)?
18181        {
18182            reconcile.push(entry.table_id);
18183        }
18184    }
18185    for table_id in ttl_updates.keys() {
18186        if !catalog.tables.iter().any(|entry| {
18187            entry.table_id == *table_id
18188                && matches!(entry.state, TableState::Live | TableState::Building { .. })
18189        }) {
18190            continue;
18191        }
18192        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
18193        let table_exists = match durable_root {
18194            Some(root) => match root.open_directory(&relative_dir) {
18195                Ok(_) => true,
18196                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
18197                Err(error) => return Err(error.into()),
18198            },
18199            None => root.join(&relative_dir).is_dir(),
18200        };
18201        if !table_exists && !created_table_ids.contains(table_id) {
18202            return Err(MongrelError::NotFound(format!(
18203                "TTL recovery table {table_id} storage is missing"
18204            )));
18205        }
18206    }
18207    reconcile.sort_unstable();
18208    reconcile.dedup();
18209    Ok(reconcile)
18210}
18211
18212fn validate_catalog_table_storage(
18213    root: &crate::durable_file::DurableRoot,
18214    catalog: &Catalog,
18215    meta_dek: Option<&[u8; META_DEK_LEN]>,
18216) -> Result<()> {
18217    for entry in &catalog.tables {
18218        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18219            continue;
18220        }
18221        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
18222        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
18223        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
18224            return Err(MongrelError::Conflict(format!(
18225                "catalog table {} storage identity mismatch",
18226                entry.table_id
18227            )));
18228        }
18229        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
18230    }
18231    Ok(())
18232}
18233
18234fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
18235    match schema.columns.iter_mut().find(|c| c.id == column.id) {
18236        Some(existing) if *existing == column => Ok(false),
18237        Some(existing) => {
18238            *existing = column;
18239            schema.schema_id = schema
18240                .schema_id
18241                .checked_add(1)
18242                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
18243            Ok(true)
18244        }
18245        None => {
18246            schema.columns.push(column);
18247            schema.schema_id = schema
18248                .schema_id
18249                .checked_add(1)
18250                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
18251            Ok(true)
18252        }
18253    }
18254}
18255
18256fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
18257    use crate::auth::Permission;
18258    match permission {
18259        Permission::Select { table }
18260        | Permission::Insert { table }
18261        | Permission::Update { table }
18262        | Permission::Delete { table }
18263        | Permission::SelectColumns { table, .. }
18264        | Permission::InsertColumns { table, .. }
18265        | Permission::UpdateColumns { table, .. } => Some(table),
18266        Permission::All | Permission::Ddl | Permission::Admin => None,
18267    }
18268}
18269
18270fn apply_rebuilding_publish(
18271    catalog: &mut Catalog,
18272    table_id: u64,
18273    replaced_table_id: u64,
18274    new_name: &str,
18275    epoch: u64,
18276) -> Result<bool> {
18277    let already_published = catalog.tables.iter().any(|entry| {
18278        entry.table_id == table_id
18279            && entry.name == new_name
18280            && matches!(entry.state, TableState::Live)
18281    }) && catalog.tables.iter().any(|entry| {
18282        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
18283    });
18284    if already_published {
18285        return Ok(false);
18286    }
18287    let schema = catalog
18288        .tables
18289        .iter()
18290        .find(|entry| entry.table_id == table_id)
18291        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
18292        .schema
18293        .clone();
18294    let replaced = catalog
18295        .tables
18296        .iter_mut()
18297        .find(|entry| entry.table_id == replaced_table_id)
18298        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
18299    replaced.state = TableState::Dropped { at_epoch: epoch };
18300    let replacement = catalog
18301        .tables
18302        .iter_mut()
18303        .find(|entry| entry.table_id == table_id)
18304        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
18305    replacement.name = new_name.to_string();
18306    replacement.state = TableState::Live;
18307
18308    for role in &mut catalog.roles {
18309        role.permissions.retain_mut(|permission| {
18310            retain_rebuilt_permission_columns(permission, new_name, &schema)
18311        });
18312    }
18313    for definition in &mut catalog.materialized_views {
18314        if let Some(incremental) = definition.incremental.as_mut() {
18315            if incremental.source_table == new_name
18316                && incremental.source_table_id == replaced_table_id
18317            {
18318                incremental.source_table_id = table_id;
18319            }
18320        }
18321    }
18322    advance_security_version(catalog)?;
18323    Ok(true)
18324}
18325
18326fn retain_rebuilt_permission_columns(
18327    permission: &mut crate::auth::Permission,
18328    target_table: &str,
18329    schema: &Schema,
18330) -> bool {
18331    use crate::auth::Permission;
18332    let columns = match permission {
18333        Permission::SelectColumns { table, columns }
18334        | Permission::InsertColumns { table, columns }
18335        | Permission::UpdateColumns { table, columns }
18336            if table == target_table =>
18337        {
18338            Some(columns)
18339        }
18340        _ => None,
18341    };
18342    if let Some(columns) = columns {
18343        columns.retain(|column| schema.column(column).is_some());
18344        !columns.is_empty()
18345    } else {
18346        true
18347    }
18348}
18349
18350fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
18351    use crate::auth::Permission;
18352    let table = match permission {
18353        Permission::Select { table }
18354        | Permission::Insert { table }
18355        | Permission::Update { table }
18356        | Permission::Delete { table }
18357        | Permission::SelectColumns { table, .. }
18358        | Permission::InsertColumns { table, .. }
18359        | Permission::UpdateColumns { table, .. } => Some(table),
18360        Permission::All | Permission::Ddl | Permission::Admin => None,
18361    };
18362    if let Some(table) = table.filter(|table| table.as_str() == old) {
18363        *table = new.to_string();
18364    }
18365}
18366
18367fn rename_permission_column(
18368    permission: &mut crate::auth::Permission,
18369    target_table: &str,
18370    old: &str,
18371    new: &str,
18372) {
18373    use crate::auth::Permission;
18374    let columns = match permission {
18375        Permission::SelectColumns { table, columns }
18376        | Permission::InsertColumns { table, columns }
18377        | Permission::UpdateColumns { table, columns }
18378            if table == target_table =>
18379        {
18380            Some(columns)
18381        }
18382        _ => None,
18383    };
18384    if let Some(column) = columns
18385        .into_iter()
18386        .flatten()
18387        .find(|column| column.as_str() == old)
18388    {
18389        *column = new.to_string();
18390    }
18391}
18392
18393pub(crate) fn validate_security_catalog(
18394    catalog: &Catalog,
18395    security: &crate::security::SecurityCatalog,
18396) -> Result<()> {
18397    let mut policy_names = HashSet::new();
18398    for table in &security.rls_tables {
18399        if catalog.live(table).is_none() {
18400            return Err(MongrelError::NotFound(format!(
18401                "RLS table {table:?} not found"
18402            )));
18403        }
18404    }
18405    for policy in &security.policies {
18406        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
18407            return Err(MongrelError::InvalidArgument(format!(
18408                "duplicate policy {:?} on {:?}",
18409                policy.name, policy.table
18410            )));
18411        }
18412        let schema = &catalog
18413            .live(&policy.table)
18414            .ok_or_else(|| {
18415                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
18416            })?
18417            .schema;
18418        if let Some(expression) = &policy.using {
18419            validate_security_expression(expression, schema)?;
18420        }
18421        if let Some(expression) = &policy.with_check {
18422            validate_security_expression(expression, schema)?;
18423        }
18424    }
18425    let mut mask_names = HashSet::new();
18426    for mask in &security.masks {
18427        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
18428            return Err(MongrelError::InvalidArgument(format!(
18429                "duplicate mask {:?} on {:?}",
18430                mask.name, mask.table
18431            )));
18432        }
18433        let column = catalog
18434            .live(&mask.table)
18435            .and_then(|entry| {
18436                entry
18437                    .schema
18438                    .columns
18439                    .iter()
18440                    .find(|column| column.id == mask.column)
18441            })
18442            .ok_or_else(|| {
18443                MongrelError::NotFound(format!(
18444                    "mask column {} on {:?} not found",
18445                    mask.column, mask.table
18446                ))
18447            })?;
18448        if matches!(
18449            mask.strategy,
18450            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
18451        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
18452        {
18453            return Err(MongrelError::InvalidArgument(format!(
18454                "mask {:?} requires a string/bytes column",
18455                mask.name
18456            )));
18457        }
18458    }
18459    Ok(())
18460}
18461
18462fn validate_security_expression(
18463    expression: &crate::security::SecurityExpr,
18464    schema: &Schema,
18465) -> Result<()> {
18466    use crate::security::SecurityExpr;
18467    match expression {
18468        SecurityExpr::True => Ok(()),
18469        SecurityExpr::ColumnEqCurrentUser { column }
18470        | SecurityExpr::ColumnEqValue { column, .. } => {
18471            if schema
18472                .columns
18473                .iter()
18474                .any(|candidate| candidate.id == *column)
18475            {
18476                Ok(())
18477            } else {
18478                Err(MongrelError::InvalidArgument(format!(
18479                    "security expression references unknown column id {column}"
18480                )))
18481            }
18482        }
18483        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
18484            validate_security_expression(left, schema)?;
18485            validate_security_expression(right, schema)
18486        }
18487        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
18488    }
18489}
18490
18491/// Remove canonical numeric table directories that no catalog generation owns.
18492fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
18493    let referenced = cat
18494        .tables
18495        .iter()
18496        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
18497        .map(|entry| entry.table_id)
18498        .collect::<HashSet<_>>();
18499    let tables_dir = root.join(TABLES_DIR);
18500    let entries = match std::fs::read_dir(&tables_dir) {
18501        Ok(entries) => entries,
18502        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
18503        Err(error) => return Err(error.into()),
18504    };
18505    for entry in entries {
18506        let entry = entry?;
18507        if !entry.file_type()?.is_dir() {
18508            continue;
18509        }
18510        let file_name = entry.file_name();
18511        let Some(name) = file_name.to_str() else {
18512            continue;
18513        };
18514        let Ok(table_id) = name.parse::<u64>() else {
18515            continue;
18516        };
18517        if name != table_id.to_string() {
18518            continue;
18519        }
18520        if !referenced.contains(&table_id) {
18521            crate::durable_file::remove_directory_all(&entry.path())?;
18522        }
18523    }
18524    Ok(())
18525}
18526
18527/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
18528/// #14). These dirs hold pending uniform-epoch runs from large transactions
18529/// that were aborted or crashed before commit. On open, all such dirs are safe
18530/// to remove because committed txns moved their runs to `_runs/` at publish.
18531fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
18532    for entry in &cat.tables {
18533        let txn_dir = root
18534            .join(TABLES_DIR)
18535            .join(entry.table_id.to_string())
18536            .join("_txn");
18537        if txn_dir.exists() {
18538            let _ = std::fs::remove_dir_all(&txn_dir);
18539        }
18540    }
18541}
18542
18543#[cfg(test)]
18544mod write_permission_tests {
18545    use super::*;
18546    use crate::txn::Staged;
18547
18548    struct NoopExternalBridge;
18549
18550    impl ExternalTriggerBridge for NoopExternalBridge {
18551        fn apply_trigger_external_write(
18552            &self,
18553            _entry: &ExternalTableEntry,
18554            base_state: Vec<u8>,
18555            _op: ExternalTriggerWrite,
18556        ) -> Result<ExternalTriggerWriteResult> {
18557            Ok(ExternalTriggerWriteResult::new(base_state))
18558        }
18559    }
18560
18561    fn assert_txn_namespace_full<T>(result: Result<T>) {
18562        assert!(matches!(result, Err(MongrelError::Full(_))));
18563    }
18564
18565    #[test]
18566    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
18567        let directory = tempfile::tempdir().unwrap();
18568        let database = Database::create(directory.path()).unwrap();
18569        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
18570        *database.next_txn_id.lock() = generation << 32;
18571        let before = crate::wal::SharedWal::replay(directory.path())
18572            .unwrap()
18573            .len();
18574        let bridge = NoopExternalBridge;
18575
18576        assert_txn_namespace_full(database.begin().commit());
18577        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
18578        assert_txn_namespace_full(
18579            database
18580                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
18581                .commit(),
18582        );
18583        assert_txn_namespace_full(
18584            database
18585                .begin_with_external_trigger_bridge(&bridge)
18586                .commit(),
18587        );
18588        assert_txn_namespace_full(
18589            database
18590                .begin_with_external_trigger_bridge_as(&bridge, None)
18591                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
18592        );
18593
18594        assert_eq!(
18595            crate::wal::SharedWal::replay(directory.path())
18596                .unwrap()
18597                .len(),
18598            before
18599        );
18600        drop(database);
18601        Database::open(directory.path()).unwrap();
18602    }
18603
18604    #[test]
18605    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
18606        let directory = tempfile::tempdir().unwrap();
18607        let table_dir = directory.path().join("7");
18608        crate::durable_file::create_directory_all(&table_dir).unwrap();
18609        let original_schema = test_schema();
18610        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
18611        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
18612        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
18613        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
18614        let original_bytes = std::fs::read(&schema_path).unwrap();
18615
18616        let mut replacement_schema = original_schema;
18617        replacement_schema.schema_id += 1;
18618        assert!(matches!(
18619            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
18620            Err(MongrelError::Conflict(_))
18621        ));
18622
18623        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
18624        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
18625        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
18626        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
18627    }
18628
18629    #[test]
18630    fn catalog_table_missing_storage_fails_without_recreating_it() {
18631        let directory = tempfile::tempdir().unwrap();
18632        let table_dir = {
18633            let database = Database::create(directory.path()).unwrap();
18634            database.create_table("docs", test_schema()).unwrap();
18635            directory
18636                .path()
18637                .join(TABLES_DIR)
18638                .join(database.table_id("docs").unwrap().to_string())
18639        };
18640        std::fs::remove_dir_all(&table_dir).unwrap();
18641
18642        assert!(matches!(
18643            Database::open(directory.path()),
18644            Err(MongrelError::NotFound(_))
18645        ));
18646        assert!(!table_dir.exists());
18647    }
18648
18649    #[test]
18650    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
18651        let directory = tempfile::tempdir().unwrap();
18652        let database = std::sync::Arc::new(
18653            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
18654        );
18655        database.create_user("alice", "old-password").unwrap();
18656        let old_identity = database.user_identity("alice").unwrap();
18657        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
18658        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
18659        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
18660        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
18661
18662        std::thread::scope(|scope| {
18663            let authenticate = {
18664                let database = std::sync::Arc::clone(&database);
18665                scope.spawn(move || {
18666                    database.authenticate_principal_inner("alice", "old-password", || {
18667                        verified_tx.send(()).unwrap();
18668                        resume_rx.recv().unwrap();
18669                    })
18670                })
18671            };
18672            verified_rx.recv().unwrap();
18673            let mutate = {
18674                let database = std::sync::Arc::clone(&database);
18675                scope.spawn(move || {
18676                    mutation_started_tx.send(()).unwrap();
18677                    database.drop_user("alice").unwrap();
18678                    database.create_user("alice", "new-password").unwrap();
18679                    mutation_done_tx.send(()).unwrap();
18680                })
18681            };
18682            mutation_started_rx.recv().unwrap();
18683            assert!(mutation_done_rx
18684                .recv_timeout(std::time::Duration::from_millis(50))
18685                .is_err());
18686            resume_tx.send(()).unwrap();
18687            let principal = authenticate.join().unwrap().unwrap().unwrap();
18688            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
18689            mutate.join().unwrap();
18690        });
18691
18692        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
18693        assert!(database
18694            .authenticate_principal("alice", "old-password")
18695            .unwrap()
18696            .is_none());
18697        assert!(database
18698            .authenticate_principal("alice", "new-password")
18699            .unwrap()
18700            .is_some());
18701    }
18702
18703    #[test]
18704    fn homogeneous_batch_summarizes_to_one_permission_decision() {
18705        let staging = (0..10_050)
18706            .map(|_| {
18707                (
18708                    7,
18709                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
18710                )
18711            })
18712            .collect::<Vec<_>>();
18713
18714        let needs = summarize_write_permissions(&staging);
18715        let table = needs.get(&7).unwrap();
18716        assert_eq!(needs.len(), 1);
18717        assert!(table.insert);
18718        assert_eq!(table.insert_columns, [1, 2]);
18719        assert!(!table.update);
18720        assert!(!table.delete);
18721        assert!(!table.truncate);
18722    }
18723
18724    #[test]
18725    fn mixed_writes_union_columns_and_preserve_empty_operations() {
18726        let staging = vec![
18727            (7, Staged::Put(vec![(2, Value::Int64(2))])),
18728            (7, Staged::Put(vec![(1, Value::Int64(1))])),
18729            (
18730                7,
18731                Staged::Update {
18732                    row_id: RowId(1),
18733                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
18734                    changed_columns: vec![2],
18735                },
18736            ),
18737            (7, Staged::Delete(RowId(2))),
18738            (8, Staged::Truncate),
18739        ];
18740
18741        let needs = summarize_write_permissions(&staging);
18742        let table = needs.get(&7).unwrap();
18743        assert_eq!(table.insert_columns, [1, 2]);
18744        assert!(table.update);
18745        assert_eq!(table.update_columns, [2]);
18746        assert!(table.delete);
18747        assert!(needs.get(&8).unwrap().truncate);
18748    }
18749
18750    #[test]
18751    fn final_permission_decisions_do_not_scale_with_rows() {
18752        let credentialless_dir = tempfile::tempdir().unwrap();
18753        let credentialless = Database::create(credentialless_dir.path()).unwrap();
18754        credentialless.create_table("docs", test_schema()).unwrap();
18755        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18756        credentialless
18757            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
18758            .unwrap();
18759        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
18760
18761        let authenticated_dir = tempfile::tempdir().unwrap();
18762        let authenticated =
18763            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
18764                .unwrap();
18765        authenticated.create_table("docs", test_schema()).unwrap();
18766        let admin = authenticated.resolve_principal("admin").unwrap();
18767        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18768        authenticated
18769            .validate_write_permissions(
18770                &puts(authenticated.table_id("docs").unwrap()),
18771                Some(&admin),
18772                None,
18773            )
18774            .unwrap();
18775        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
18776    }
18777
18778    #[test]
18779    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
18780        let dir = tempfile::tempdir().unwrap();
18781        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
18782        db.create_table("docs", test_schema()).unwrap();
18783        let admin = db.resolve_principal("admin").unwrap();
18784        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18785
18786        let mut transaction = db.begin_as(Some(admin));
18787        transaction
18788            .delete_batch("docs", (0..100).map(RowId).collect())
18789            .unwrap();
18790        transaction.commit().unwrap();
18791
18792        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
18793    }
18794
18795    #[test]
18796    fn truncate_validation_checks_admin_once_for_all_tables() {
18797        let dir = tempfile::tempdir().unwrap();
18798        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
18799        db.create_table("first", test_schema()).unwrap();
18800        db.create_table("second", test_schema()).unwrap();
18801        let admin = db.resolve_principal("admin").unwrap();
18802        let staging = vec![
18803            (db.table_id("first").unwrap(), Staged::Truncate),
18804            (db.table_id("second").unwrap(), Staged::Truncate),
18805        ];
18806
18807        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
18808        db.validate_write_permissions(&staging, Some(&admin), None)
18809            .unwrap();
18810        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
18811    }
18812
18813    #[test]
18814    fn one_table_commit_batches_structural_work() {
18815        let dir = tempfile::tempdir().unwrap();
18816        let db = Database::create(dir.path()).unwrap();
18817        db.create_table("docs", test_schema()).unwrap();
18818        let table_id = db.table_id("docs").unwrap();
18819
18820        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
18821        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
18822        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
18823        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
18824        db.transaction(|transaction| {
18825            for id in 0..100 {
18826                transaction.put("docs", vec![(1, Value::Int64(id))])?;
18827            }
18828            Ok(())
18829        })
18830        .unwrap();
18831
18832        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
18833        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18834        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18835        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
18836
18837        let puts = crate::wal::SharedWal::replay(dir.path())
18838            .unwrap()
18839            .into_iter()
18840            .filter_map(|record| match record.op {
18841                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
18842                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
18843                        .unwrap()
18844                        .len(),
18845                ),
18846                _ => None,
18847            })
18848            .collect::<Vec<_>>();
18849        assert_eq!(puts, [100]);
18850
18851        let row_ids = db
18852            .table("docs")
18853            .unwrap()
18854            .lock()
18855            .visible_rows(db.snapshot().0)
18856            .unwrap()
18857            .into_iter()
18858            .take(2)
18859            .map(|row| row.row_id)
18860            .collect::<Vec<_>>();
18861        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
18862        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
18863        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
18864        db.transaction(|transaction| {
18865            for row_id in row_ids {
18866                transaction.delete("docs", row_id)?;
18867            }
18868            Ok(())
18869        })
18870        .unwrap();
18871        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18872        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
18873        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
18874
18875        let deletes = crate::wal::SharedWal::replay(dir.path())
18876            .unwrap()
18877            .into_iter()
18878            .filter_map(|record| match record.op {
18879                crate::wal::Op::Delete {
18880                    table_id: id,
18881                    row_ids,
18882                } if id == table_id => Some(row_ids.len()),
18883                _ => None,
18884            })
18885            .collect::<Vec<_>>();
18886        assert_eq!(deletes, [2]);
18887    }
18888
18889    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
18890        (0..10_050)
18891            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
18892            .collect()
18893    }
18894
18895    fn test_schema() -> Schema {
18896        Schema {
18897            columns: vec![ColumnDef {
18898                id: 1,
18899                name: "id".into(),
18900                ty: TypeId::Int64,
18901                flags: crate::schema::ColumnFlags::empty()
18902                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
18903                default_value: None,
18904                embedding_source: None,
18905            }],
18906            ..Schema::default()
18907        }
18908    }
18909}
18910
18911#[cfg(test)]
18912mod cdc_bounds_tests {
18913    use super::*;
18914
18915    #[test]
18916    fn retained_byte_limit_rejects_without_allocating_payload() {
18917        let mut retained = 0;
18918        let error = charge_cdc_bytes(
18919            &mut retained,
18920            CDC_MAX_RETAINED_BYTES.saturating_add(1),
18921            "CDC retained bytes",
18922        )
18923        .unwrap_err();
18924        assert!(matches!(
18925            error,
18926            MongrelError::ResourceLimitExceeded {
18927                resource: "CDC retained bytes",
18928                ..
18929            }
18930        ));
18931    }
18932
18933    #[test]
18934    fn row_json_estimate_accounts_for_byte_array_expansion() {
18935        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
18936            .with_column(1, Value::Bytes(vec![0; 1024]));
18937        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
18938    }
18939}
18940
18941#[cfg(test)]
18942mod generation_metrics_tests {
18943    use super::*;
18944    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
18945
18946    #[test]
18947    fn legacy_cow_fallback_is_measured() {
18948        let dir = tempfile::tempdir().unwrap();
18949        let table = Table::create(
18950            dir.path(),
18951            Schema {
18952                columns: vec![ColumnDef {
18953                    id: 1,
18954                    name: "id".into(),
18955                    ty: TypeId::Int64,
18956                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
18957                    default_value: None,
18958                    embedding_source: None,
18959                }],
18960                ..Schema::default()
18961            },
18962            1,
18963        )
18964        .unwrap();
18965        let handle = TableHandle::from_table(table);
18966        let held = match &handle.inner {
18967            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
18968            TableHandleInner::Direct(_) => unreachable!(),
18969        };
18970
18971        handle.lock().set_sync_byte_threshold(1);
18972
18973        let stats = handle.generation_stats();
18974        assert_eq!(stats.cow_clone_count, 1);
18975        assert!(stats.estimated_cow_clone_bytes > 0);
18976        drop(held);
18977    }
18978}
18979
18980#[cfg(test)]
18981mod trigger_engine_tests {
18982    use super::*;
18983
18984    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
18985        WriteEvent {
18986            table: "test".into(),
18987            kind: TriggerEvent::Insert,
18988            new: Some(TriggerRowImage {
18989                columns: new_cells.iter().cloned().collect(),
18990            }),
18991            old: Some(TriggerRowImage {
18992                columns: old_cells.iter().cloned().collect(),
18993            }),
18994            changed_columns: Vec::new(),
18995            op_indices: Vec::new(),
18996            put_idx: None,
18997            trigger_stack: Vec::new(),
18998        }
18999    }
19000
19001    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
19002        WriteEvent {
19003            table: "test".into(),
19004            kind: TriggerEvent::Insert,
19005            new: Some(TriggerRowImage {
19006                columns: new_cells.iter().cloned().collect(),
19007            }),
19008            old: None,
19009            changed_columns: Vec::new(),
19010            op_indices: Vec::new(),
19011            put_idx: None,
19012            trigger_stack: Vec::new(),
19013        }
19014    }
19015
19016    #[test]
19017    fn value_order_int64_vs_float64() {
19018        assert_eq!(
19019            value_order(&Value::Int64(5), &Value::Float64(5.0)),
19020            Some(std::cmp::Ordering::Equal)
19021        );
19022        assert_eq!(
19023            value_order(&Value::Int64(5), &Value::Float64(3.0)),
19024            Some(std::cmp::Ordering::Greater)
19025        );
19026        assert_eq!(
19027            value_order(&Value::Int64(2), &Value::Float64(3.0)),
19028            Some(std::cmp::Ordering::Less)
19029        );
19030    }
19031
19032    #[test]
19033    fn value_order_null_returns_none() {
19034        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
19035        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
19036        assert_eq!(value_order(&Value::Null, &Value::Null), None);
19037    }
19038
19039    #[test]
19040    fn value_order_cross_group_returns_none() {
19041        assert_eq!(
19042            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
19043            None
19044        );
19045        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
19046        assert_eq!(
19047            value_order(
19048                &Value::Embedding(vec![1.0, 2.0]),
19049                &Value::Embedding(vec![1.0, 2.0])
19050            ),
19051            None
19052        );
19053    }
19054
19055    #[test]
19056    fn eval_trigger_expr_ranges_and_booleans() {
19057        let expr = TriggerExpr::And {
19058            left: Box::new(TriggerExpr::Gt {
19059                left: TriggerValue::NewColumn(1),
19060                right: TriggerValue::Literal(Value::Int64(0)),
19061            }),
19062            right: Box::new(TriggerExpr::Lte {
19063                left: TriggerValue::NewColumn(1),
19064                right: TriggerValue::Literal(Value::Int64(100)),
19065            }),
19066        };
19067        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
19068        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
19069        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
19070
19071        let or_expr = TriggerExpr::Or {
19072            left: Box::new(TriggerExpr::Lt {
19073                left: TriggerValue::NewColumn(1),
19074                right: TriggerValue::Literal(Value::Int64(0)),
19075            }),
19076            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
19077                TriggerValue::OldColumn(2),
19078            )))),
19079        };
19080        assert!(eval_trigger_expr(
19081            &or_expr,
19082            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
19083        )
19084        .unwrap());
19085        assert!(!eval_trigger_expr(
19086            &or_expr,
19087            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
19088        )
19089        .unwrap());
19090
19091        assert!(eval_trigger_expr(
19092            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
19093            &event_insert(&[])
19094        )
19095        .unwrap());
19096        assert!(!eval_trigger_expr(
19097            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
19098            &event_insert(&[])
19099        )
19100        .unwrap());
19101        assert!(!eval_trigger_expr(
19102            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
19103            &event_insert(&[])
19104        )
19105        .unwrap());
19106    }
19107}
19108
19109#[cfg(test)]
19110mod core_resource_tests {
19111    use super::*;
19112
19113    fn int_pk_schema() -> Schema {
19114        Schema {
19115            columns: vec![ColumnDef {
19116                id: 1,
19117                name: "id".into(),
19118                ty: TypeId::Int64,
19119                flags: crate::schema::ColumnFlags::empty()
19120                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19121                default_value: None,
19122                embedding_source: None,
19123            }],
19124            ..Schema::default()
19125        }
19126    }
19127
19128    #[test]
19129    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
19130        let dir = tempfile::tempdir().unwrap();
19131        let db = Database::create(dir.path()).unwrap();
19132        assert_eq!(
19133            db.memory_governor().max_bytes(),
19134            DEFAULT_MEMORY_BUDGET_BYTES
19135        );
19136        assert_eq!(
19137            db.spill_manager().config().global_bytes,
19138            DEFAULT_TEMP_DISK_BUDGET_BYTES
19139        );
19140        assert!(db.job_registry().list().is_empty());
19141        // S1E-002: class defaults seeded at open; no external embedding vendor.
19142        assert_eq!(
19143            db.resource_groups().len(),
19144            crate::resource::WorkloadClass::ALL.len()
19145        );
19146        assert!(db.resource_groups().get("control").is_some());
19147        assert!(db.embedding_providers().list_ids().is_empty());
19148        // Application-supplied path refuses generation (no silent hashed vectors).
19149        let err = db
19150            .embedding_providers()
19151            .embed(
19152                &crate::embedding::EmbeddingSource::SuppliedByApplication,
19153                &["text"],
19154                4,
19155            )
19156            .unwrap_err();
19157        assert!(matches!(
19158            err,
19159            crate::embedding::EmbeddingError::SuppliedByApplication
19160        ));
19161    }
19162
19163    #[test]
19164    fn lock_rows_for_update_acquires_exclusive_row_locks() {
19165        use crate::locks::LockKey;
19166        use crate::rowid::RowId;
19167
19168        let dir = tempfile::tempdir().unwrap();
19169        let db = Database::create(dir.path()).unwrap();
19170        let txn_id = db.allocate_lock_txn_id().unwrap();
19171        let rid = RowId(42);
19172        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
19173        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
19174        db.release_txn_locks(txn_id);
19175        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
19176    }
19177
19178    #[test]
19179    fn open_with_options_sizes_the_core_budgets() {
19180        let dir = tempfile::tempdir().unwrap();
19181        let db = Database::create(dir.path()).unwrap();
19182        drop(db);
19183        let db = Database::open_with_options(
19184            dir.path(),
19185            OpenOptions::default()
19186                .with_memory_budget_bytes(256 * 1024 * 1024)
19187                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
19188        )
19189        .unwrap();
19190        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
19191        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
19192    }
19193
19194    #[test]
19195    fn zero_budgets_are_rejected() {
19196        let dir = tempfile::tempdir().unwrap();
19197        let db = Database::create(dir.path()).unwrap();
19198        drop(db);
19199        let result = Database::open_with_options(
19200            dir.path(),
19201            OpenOptions::default().with_memory_budget_bytes(0),
19202        );
19203        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
19204        let result = Database::open_with_options(
19205            dir.path(),
19206            OpenOptions::default().with_temp_disk_budget_bytes(0),
19207        );
19208        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
19209    }
19210
19211    #[test]
19212    fn page_caches_reserve_under_the_governor() {
19213        let dir = tempfile::tempdir().unwrap();
19214        let db = Database::create(dir.path()).unwrap();
19215        db.create_table("t", int_pk_schema()).unwrap();
19216        let mut txn = db.begin();
19217        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
19218        txn.commit().unwrap();
19219        // S1E-003: both caches hold reservations under the core's governor, so
19220        // the governor's per-class accounting mirrors their live bytes, and
19221        // both are registered as reclaimable subsystems it can evict.
19222        let stats = db.memory_governor().stats();
19223        assert_eq!(
19224            stats.usage_for(crate::memory::MemoryClass::PageCache),
19225            db.page_cache.used_bytes()
19226        );
19227        assert_eq!(
19228            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
19229            db.decoded_cache.used_bytes()
19230        );
19231        assert_eq!(
19232            db.memory_governor().reclaimable_bytes(),
19233            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
19234        );
19235        // Driving an eviction through the governor is safe at any level.
19236        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
19237    }
19238
19239    #[test]
19240    fn job_registry_persists_across_reopen() {
19241        let dir = tempfile::tempdir().unwrap();
19242        let db = Database::create(dir.path()).unwrap();
19243        db.create_table("t", int_pk_schema()).unwrap();
19244        let job_id = db
19245            .job_registry()
19246            .submit(
19247                crate::jobs::JobKind::IndexBuild,
19248                crate::jobs::JobTarget {
19249                    table: "t".to_string(),
19250                    index: Some("idx".to_string()),
19251                },
19252            )
19253            .unwrap();
19254        drop(db);
19255        let db = Database::open(dir.path()).unwrap();
19256        let record = db.job_registry().get(job_id).expect("job survives reopen");
19257        assert_eq!(record.state, crate::jobs::JobState::Pending);
19258    }
19259
19260    #[test]
19261    fn spill_manager_open_sweeps_stale_temp_tree() {
19262        let dir = tempfile::tempdir().unwrap();
19263        let db = Database::create(dir.path()).unwrap();
19264        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
19265        std::fs::create_dir_all(&stale).unwrap();
19266        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
19267        drop(db);
19268        let db = Database::open(dir.path()).unwrap();
19269        assert!(
19270            !stale.exists(),
19271            "the startup sweep removes stale spill files (S1E-004)"
19272        );
19273        // A fresh session can start against the swept manager.
19274        let session = db
19275            .spill_manager()
19276            .begin_query(
19277                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
19278                1024 * 1024,
19279            )
19280            .unwrap();
19281        assert_eq!(session.used(), 0);
19282    }
19283}
19284
19285#[cfg(test)]
19286mod version_pin_tests {
19287    use super::*;
19288
19289    fn int_pk_schema() -> Schema {
19290        Schema {
19291            columns: vec![ColumnDef {
19292                id: 1,
19293                name: "id".into(),
19294                ty: TypeId::Int64,
19295                flags: crate::schema::ColumnFlags::empty()
19296                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19297                default_value: None,
19298                embedding_source: None,
19299            }],
19300            ..Schema::default()
19301        }
19302    }
19303
19304    fn pins_for(
19305        report: &[TablePinsReport],
19306        table: &str,
19307        source: crate::retention::PinSource,
19308    ) -> Option<crate::retention::PinInfo> {
19309        report
19310            .iter()
19311            .find(|entry| entry.table == table)
19312            .and_then(|entry| entry.pins.get(source).cloned())
19313    }
19314
19315    #[test]
19316    fn backup_boundary_registers_backup_pitr_pin() {
19317        let source = tempfile::tempdir().unwrap();
19318        let destination_parent = tempfile::tempdir().unwrap();
19319        let destination = destination_parent.path().join("backup");
19320        let db = Arc::new(Database::create(source.path()).unwrap());
19321        db.create_table("t", int_pk_schema()).unwrap();
19322        let mut txn = db.begin();
19323        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
19324        let boundary_epoch = txn.commit().unwrap();
19325
19326        let hold = Arc::new(std::sync::Barrier::new(2));
19327        let resume = Arc::new(std::sync::Barrier::new(2));
19328        db.__set_backup_hook({
19329            let hold = Arc::clone(&hold);
19330            let resume = Arc::clone(&resume);
19331            move || {
19332                hold.wait();
19333                resume.wait();
19334            }
19335        });
19336
19337        let backup = {
19338            let db = Arc::clone(&db);
19339            let destination = destination.clone();
19340            std::thread::spawn(move || db.hot_backup(destination))
19341        };
19342        hold.wait();
19343        // The hook fires while the backup's pins are held: the boundary must
19344        // show up as a BackupPitr pin on the table's unified registry.
19345        let report = db.version_pins_report();
19346        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
19347            .expect("backup boundary must register a BackupPitr pin");
19348        assert_eq!(pin.oldest_epoch, boundary_epoch);
19349        assert!(pin.pin_count >= 1);
19350        resume.wait();
19351        backup.join().unwrap().unwrap();
19352
19353        let report = db.version_pins_report();
19354        assert!(
19355            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
19356            "the BackupPitr pin releases when the backup finishes"
19357        );
19358    }
19359
19360    #[test]
19361    fn snapshot_and_read_generation_pins_surface_in_report() {
19362        let dir = tempfile::tempdir().unwrap();
19363        let db = Database::create(dir.path()).unwrap();
19364        db.create_table("t", int_pk_schema()).unwrap();
19365        let mut txn = db.begin();
19366        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
19367        txn.commit().unwrap();
19368
19369        let (_snapshot, guard) = db.snapshot();
19370        let report = db.version_pins_report();
19371        assert!(
19372            pins_for(
19373                &report,
19374                "t",
19375                crate::retention::PinSource::TransactionSnapshot
19376            )
19377            .is_some(),
19378            "a database snapshot projects the TransactionSnapshot source"
19379        );
19380        drop(guard);
19381
19382        let handle = db.table("t").unwrap();
19383        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
19384        let report = db.version_pins_report();
19385        assert!(
19386            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
19387            "a cloned read generation registers a ReadGeneration pin"
19388        );
19389        drop(generation);
19390
19391        let report = db.version_pins_report();
19392        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
19393        assert!(
19394            entry
19395                .pins
19396                .get(crate::retention::PinSource::BackupPitr)
19397                .is_none()
19398                && entry
19399                    .pins
19400                    .get(crate::retention::PinSource::Replication)
19401                    .is_none()
19402                && entry
19403                    .pins
19404                    .get(crate::retention::PinSource::OnlineIndexBuild)
19405                    .is_none(),
19406            "untaken sources stay absent from the report"
19407        );
19408    }
19409}
19410
19411#[cfg(test)]
19412mod lock_manager_tests {
19413    use super::*;
19414    use crate::locks::LockKey;
19415
19416    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
19417        ColumnDef {
19418            id,
19419            name: name.into(),
19420            ty,
19421            flags,
19422            default_value: None,
19423            embedding_source: None,
19424        }
19425    }
19426
19427    fn unique_schema() -> Schema {
19428        let mut constraints = crate::constraint::TableConstraints::default();
19429        constraints
19430            .uniques
19431            .push(crate::constraint::UniqueConstraint {
19432                id: 1,
19433                name: "users_email_unique".into(),
19434                columns: vec![1],
19435            });
19436        Schema {
19437            columns: vec![
19438                col(
19439                    0,
19440                    "id",
19441                    TypeId::Int64,
19442                    crate::schema::ColumnFlags::empty()
19443                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19444                ),
19445                col(
19446                    1,
19447                    "email",
19448                    TypeId::Bytes,
19449                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
19450                ),
19451            ],
19452            constraints,
19453            ..Schema::default()
19454        }
19455    }
19456
19457    fn parent_schema() -> Schema {
19458        Schema {
19459            columns: vec![col(
19460                0,
19461                "id",
19462                TypeId::Int64,
19463                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
19464            )],
19465            ..Schema::default()
19466        }
19467    }
19468
19469    fn child_schema() -> Schema {
19470        let mut constraints = crate::constraint::TableConstraints::default();
19471        constraints
19472            .foreign_keys
19473            .push(crate::constraint::ForeignKey {
19474                id: 1,
19475                name: "child_parent_fk".into(),
19476                columns: vec![1],
19477                ref_table: "parent".into(),
19478                ref_columns: vec![0],
19479                on_delete: crate::constraint::FkAction::Restrict,
19480                on_update: crate::constraint::FkAction::Restrict,
19481            });
19482        Schema {
19483            columns: vec![
19484                col(
19485                    0,
19486                    "id",
19487                    TypeId::Int64,
19488                    crate::schema::ColumnFlags::empty()
19489                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19490                ),
19491                col(
19492                    1,
19493                    "pid",
19494                    TypeId::Int64,
19495                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
19496                ),
19497            ],
19498            constraints,
19499            ..Schema::default()
19500        }
19501    }
19502
19503    fn auto_inc_schema() -> Schema {
19504        Schema {
19505            columns: vec![col(
19506                0,
19507                "id",
19508                TypeId::Int64,
19509                crate::schema::ColumnFlags::empty()
19510                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
19511                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
19512            )],
19513            ..Schema::default()
19514        }
19515    }
19516
19517    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
19518        let mut key = b"pk:".to_vec();
19519        key.extend_from_slice(&Value::Int64(value).encode_key());
19520        LockKey::key(table_id, key)
19521    }
19522
19523    #[test]
19524    fn unique_claims_serialize_concurrent_commits() {
19525        let dir = tempfile::tempdir().unwrap();
19526        let db = Arc::new(Database::create(dir.path()).unwrap());
19527        let table_id = db.create_table("users", unique_schema()).unwrap();
19528        let pk_key = pk_lock_key(table_id, 1);
19529        let entered = Arc::new(std::sync::Barrier::new(2));
19530        let resume = Arc::new(std::sync::Barrier::new(2));
19531        let parked = Arc::new(AtomicBool::new(false));
19532        db.__set_catalog_commit_hook({
19533            let entered = Arc::clone(&entered);
19534            let resume = Arc::clone(&resume);
19535            let parked = Arc::clone(&parked);
19536            move || {
19537                // Park only the first commit to reach the sequencer; later
19538                // commits pass straight through.
19539                if !parked.swap(true, Ordering::SeqCst) {
19540                    entered.wait();
19541                    resume.wait();
19542                }
19543            }
19544        });
19545
19546        let mut txn_a = db.begin();
19547        txn_a
19548            .put(
19549                "users",
19550                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
19551            )
19552            .unwrap();
19553        let a_id = txn_a.txn_id();
19554        let (a_tx, a_rx) = std::sync::mpsc::channel();
19555        let (b_tx, b_rx) = std::sync::mpsc::channel();
19556        std::thread::scope(|scope| {
19557            scope.spawn(|| {
19558                a_tx.send(txn_a.commit()).unwrap();
19559            });
19560            entered.wait();
19561            // A is parked in the sequencer holding its unique claims.
19562            assert!(
19563                db.lock_manager().holds(a_id, &pk_key),
19564                "primary-key claim must be held until the commit ends"
19565            );
19566            let mut uq_key = format!("uq{}:", 1).into_bytes();
19567            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
19568                .into_iter()
19569                .collect();
19570            uq_key.extend_from_slice(
19571                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
19572            );
19573            assert!(
19574                db.lock_manager()
19575                    .holds(a_id, &LockKey::key(table_id, uq_key)),
19576                "declared-unique claim must be held until the commit ends"
19577            );
19578
19579            let mut txn_b = db.begin();
19580            txn_b
19581                .put(
19582                    "users",
19583                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
19584                )
19585                .unwrap();
19586            scope.spawn(|| {
19587                b_tx.send(txn_b.commit()).unwrap();
19588            });
19589            std::thread::sleep(std::time::Duration::from_millis(100));
19590            assert!(
19591                b_rx.try_recv().is_err(),
19592                "the concurrent claim must block until A ends its transaction"
19593            );
19594            resume.wait();
19595            assert!(a_rx.recv().unwrap().is_ok());
19596            let b_result = b_rx.recv().unwrap();
19597            assert!(
19598                matches!(b_result, Err(MongrelError::Conflict(_))),
19599                "the loser surfaces a conflict after serializing: {b_result:?}"
19600            );
19601        });
19602        assert!(
19603            !db.lock_manager().holds(a_id, &pk_key),
19604            "no phantom holds remain after the commit"
19605        );
19606    }
19607
19608    #[test]
19609    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
19610        let dir = tempfile::tempdir().unwrap();
19611        let db = Arc::new(Database::create(dir.path()).unwrap());
19612        db.create_table("parent", parent_schema()).unwrap();
19613        db.create_table("child", child_schema()).unwrap();
19614        let mut seed = db.begin();
19615        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
19616        seed.commit().unwrap();
19617
19618        let entered = Arc::new(std::sync::Barrier::new(2));
19619        let resume = Arc::new(std::sync::Barrier::new(2));
19620        db.__set_fk_lock_hook({
19621            let entered = Arc::clone(&entered);
19622            let resume = Arc::clone(&resume);
19623            move || {
19624                entered.wait();
19625                resume.wait();
19626            }
19627        });
19628
19629        let mut txn_a = db.begin();
19630        txn_a
19631            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
19632            .unwrap();
19633        let a_id = txn_a.txn_id();
19634        let (a_tx, a_rx) = std::sync::mpsc::channel();
19635        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
19636        std::thread::scope(|scope| {
19637            scope.spawn(|| {
19638                a_tx.send(txn_a.commit()).unwrap();
19639            });
19640            entered.wait();
19641            // A is parked mid-validation: schema barrier held Shared.
19642            assert!(
19643                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
19644                "DML holds the schema barrier Shared for its commit"
19645            );
19646            let db = Arc::clone(&db);
19647            scope.spawn(move || {
19648                ddl_tx.send(db.drop_table("parent")).unwrap();
19649            });
19650            std::thread::sleep(std::time::Duration::from_millis(100));
19651            assert!(
19652                ddl_rx.try_recv().is_err(),
19653                "DDL must wait on the Exclusive schema barrier while DML is in flight"
19654            );
19655            resume.wait();
19656            // A now finishes and the waiting DDL proceeds. A's commit may
19657            // legitimately lose the publish race against the DDL's security
19658            // version advance — the designed "security policy changed during
19659            // write" outcome — or win it; the barrier guarantees only that the
19660            // DDL could not proceed before A released it.
19661            let a_result = a_rx.recv().unwrap();
19662            match &a_result {
19663                Ok(_) => {}
19664                Err(MongrelError::Conflict(message)) => {
19665                    assert!(
19666                        message.contains("security policy changed during write"),
19667                        "unexpected commit conflict: {message}"
19668                    );
19669                }
19670                other => panic!("unexpected commit outcome: {other:?}"),
19671            }
19672            assert!(ddl_rx.recv().unwrap().is_ok());
19673        });
19674        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
19675    }
19676
19677    #[test]
19678    fn auto_increment_sequence_barrier_held_until_commit() {
19679        let dir = tempfile::tempdir().unwrap();
19680        let db = Database::create(dir.path()).unwrap();
19681        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
19682        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
19683        let entered = Arc::new(std::sync::Barrier::new(2));
19684        let resume = Arc::new(std::sync::Barrier::new(2));
19685        let parked = Arc::new(AtomicBool::new(false));
19686        db.__set_catalog_commit_hook({
19687            let entered = Arc::clone(&entered);
19688            let resume = Arc::clone(&resume);
19689            let parked = Arc::clone(&parked);
19690            move || {
19691                if !parked.swap(true, Ordering::SeqCst) {
19692                    entered.wait();
19693                    resume.wait();
19694                }
19695            }
19696        });
19697
19698        let mut txn_a = db.begin();
19699        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
19700        let a_id = txn_a.txn_id();
19701        // The stage-time allocation already holds the barrier.
19702        assert!(
19703            db.lock_manager().holds(a_id, &barrier_key),
19704            "sequence allocation takes the barrier at stage time"
19705        );
19706        let (a_tx, a_rx) = std::sync::mpsc::channel();
19707        std::thread::scope(|scope| {
19708            scope.spawn(|| {
19709                a_tx.send(txn_a.commit()).unwrap();
19710            });
19711            entered.wait();
19712            assert!(
19713                db.lock_manager().holds(a_id, &barrier_key),
19714                "the barrier is held through the commit"
19715            );
19716            resume.wait();
19717            assert!(a_rx.recv().unwrap().is_ok());
19718        });
19719        assert!(
19720            !db.lock_manager().holds(a_id, &barrier_key),
19721            "the barrier releases when the commit ends"
19722        );
19723    }
19724
19725    #[test]
19726    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
19727        let dir = tempfile::tempdir().unwrap();
19728        let db = Database::create(dir.path()).unwrap();
19729        db.create_table("parent", parent_schema()).unwrap();
19730        db.create_table("child", child_schema()).unwrap();
19731        let mut seed = db.begin();
19732        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
19733        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
19734        seed.commit().unwrap();
19735        let (rid1, rid2) = {
19736            let handle = db.table("parent").unwrap();
19737            let table = handle.lock();
19738            let rid = |pk: i64| {
19739                table
19740                    .lookup_pk(&Value::Int64(pk).encode_key())
19741                    .expect("seeded parent row")
19742            };
19743            (rid(1), rid(2))
19744        };
19745
19746        // The hook fires after every successful FK lock acquisition; park the
19747        // first two (A's and B's Exclusive delete-side claims) so both are
19748        // held before either transaction attempts its Shared insert-side
19749        // claim — a deterministic wait-for cycle.
19750        let rendezvous = Arc::new(std::sync::Barrier::new(2));
19751        let calls = Arc::new(AtomicUsize::new(0));
19752        db.__set_fk_lock_hook({
19753            let rendezvous = Arc::clone(&rendezvous);
19754            let calls = Arc::clone(&calls);
19755            move || {
19756                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
19757                    rendezvous.wait();
19758                }
19759            }
19760        });
19761
19762        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
19763        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
19764        let mut txn_a = db.begin();
19765        txn_a.delete("parent", rid1).unwrap();
19766        txn_a
19767            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
19768            .unwrap();
19769        let mut txn_b = db.begin();
19770        txn_b.delete("parent", rid2).unwrap();
19771        txn_b
19772            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
19773            .unwrap();
19774        let b_id = txn_b.txn_id();
19775
19776        let (a_tx, a_rx) = std::sync::mpsc::channel();
19777        let (b_tx, b_rx) = std::sync::mpsc::channel();
19778        std::thread::scope(|scope| {
19779            scope.spawn(|| {
19780                a_tx.send(txn_a.commit()).unwrap();
19781            });
19782            scope.spawn(|| {
19783                b_tx.send(txn_b.commit()).unwrap();
19784            });
19785            let a_result = a_rx.recv().unwrap();
19786            let b_result = b_rx.recv().unwrap();
19787            assert!(
19788                a_result.is_ok(),
19789                "the survivor commits once the victim releases: {a_result:?}"
19790            );
19791            match b_result {
19792                Err(MongrelError::Deadlock { victim, .. }) => {
19793                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
19794                }
19795                other => panic!("the victim must surface a deadlock, got {other:?}"),
19796            }
19797        });
19798        // No phantom holds survive the victim's release.
19799        let fk_key = |table: &str, pk: i64| {
19800            let table_id = db.table_id(table).unwrap();
19801            let mut key = b"fk:".to_vec();
19802            key.extend_from_slice(&Value::Int64(pk).encode_key());
19803            LockKey::key(table_id, key)
19804        };
19805        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
19806        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
19807    }
19808
19809    #[test]
19810    fn locks_release_after_commit_rollback_and_failed_commit() {
19811        let dir = tempfile::tempdir().unwrap();
19812        let db = Database::create(dir.path()).unwrap();
19813        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
19814        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
19815
19816        // Successful commit: the stage-time barrier releases with the commit.
19817        let mut txn = db.begin();
19818        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
19819        let committed_id = txn.txn_id();
19820        txn.commit().unwrap();
19821        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
19822
19823        // Rollback: the stage-time barrier releases with the drop.
19824        let mut txn = db.begin();
19825        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
19826        let rolled_back_id = txn.txn_id();
19827        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
19828        txn.rollback();
19829        assert!(
19830            !db.lock_manager().holds(rolled_back_id, &barrier_key),
19831            "rollback must not leave phantom holds"
19832        );
19833
19834        // Failed commit (declared-unique violation): the claims release with
19835        // the error.
19836        db.create_table("users", unique_schema()).unwrap();
19837        let users_id = db.table_id("users").unwrap();
19838        let mut seed = db.begin();
19839        seed.put(
19840            "users",
19841            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
19842        )
19843        .unwrap();
19844        seed.commit().unwrap();
19845        let mut txn = db.begin();
19846        // A different primary key but the same declared-unique email: the
19847        // Phase B unique check rejects this commit.
19848        txn.put(
19849            "users",
19850            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
19851        )
19852        .unwrap();
19853        let failed_id = txn.txn_id();
19854        let result = txn.commit();
19855        assert!(matches!(result, Err(MongrelError::Conflict(_))));
19856        assert!(
19857            !db.lock_manager()
19858                .holds(failed_id, &pk_lock_key(users_id, 2)),
19859            "a failed commit must not leave phantom holds"
19860        );
19861    }
19862}
19863
19864#[cfg(test)]
19865mod lifecycle_tests {
19866    use super::*;
19867
19868    fn int_pk_schema() -> Schema {
19869        Schema {
19870            columns: vec![ColumnDef {
19871                id: 1,
19872                name: "id".into(),
19873                ty: TypeId::Int64,
19874                flags: crate::schema::ColumnFlags::empty()
19875                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19876                default_value: None,
19877                embedding_source: None,
19878            }],
19879            ..Schema::default()
19880        }
19881    }
19882
19883    #[test]
19884    fn poisoned_core_rejects_operations_with_typed_errors() {
19885        let dir = tempfile::tempdir().unwrap();
19886        let db = Database::create(dir.path()).unwrap();
19887        db.create_table("t", int_pk_schema()).unwrap();
19888        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
19889
19890        // Drive the exact two-state poison the fsync-error sites set
19891        // (write-path flag + lifecycle transition), without process-global
19892        // fault injection, which would leak into parallel tests. The fsync
19893        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
19894        db.poisoned.store(true, Ordering::Relaxed);
19895        db.lifecycle.poison();
19896        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
19897
19898        // Guarded operations without their own write-path poison check reject
19899        // at admission with the lifecycle Conflict...
19900        let error = db.gc().unwrap_err();
19901        assert!(
19902            matches!(error, MongrelError::Conflict(_)),
19903            "gc must reject on a poisoned core: {error:?}"
19904        );
19905        let error = db.compact().unwrap_err();
19906        assert!(
19907            matches!(error, MongrelError::Conflict(_)),
19908            "compact must reject on a poisoned core: {error:?}"
19909        );
19910        assert!(db.operation_guard().is_err());
19911        // ...while paths that already checked the write-path flag keep their
19912        // legacy error.
19913        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
19914        assert!(
19915            error.to_string().contains("database poisoned"),
19916            "the legacy poison error still wins where it existed: {error:?}"
19917        );
19918        let mut txn = db.begin();
19919        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
19920        assert!(txn
19921            .commit()
19922            .unwrap_err()
19923            .to_string()
19924            .contains("database poisoned"));
19925    }
19926
19927    #[test]
19928    fn shutdown_waits_for_operation_guards_to_drain() {
19929        let dir = tempfile::tempdir().unwrap();
19930        let db = Arc::new(Database::create(dir.path()).unwrap());
19931        db.create_table("t", int_pk_schema()).unwrap();
19932        // The guard holds the lifecycle's Arc, not the database's, so the
19933        // exclusive-owner shutdown can proceed to its drain step below.
19934        let guard = db.operation_guard().unwrap();
19935        let (started_tx, started_rx) = std::sync::mpsc::channel();
19936        let (done_tx, done_rx) = std::sync::mpsc::channel();
19937        let shutdown_thread = std::thread::spawn(move || {
19938            started_tx.send(()).unwrap();
19939            let result = db.shutdown();
19940            let _ = done_tx.send(result);
19941        });
19942        started_rx.recv().unwrap();
19943        std::thread::sleep(std::time::Duration::from_millis(100));
19944        assert!(
19945            done_rx.try_recv().is_err(),
19946            "shutdown must wait for the outstanding guard to drain"
19947        );
19948        drop(guard);
19949        shutdown_thread.join().unwrap();
19950        assert!(
19951            done_rx.recv().unwrap().is_ok(),
19952            "shutdown completes once the guard drops"
19953        );
19954    }
19955}
19956
19957#[cfg(test)]
19958mod commit_ts_ledger_tests {
19959    use super::*;
19960
19961    fn int_pk_schema() -> Schema {
19962        Schema {
19963            columns: vec![ColumnDef {
19964                id: 1,
19965                name: "id".into(),
19966                ty: TypeId::Int64,
19967                flags: crate::schema::ColumnFlags::empty()
19968                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19969                default_value: None,
19970                embedding_source: None,
19971            }],
19972            ..Schema::default()
19973        }
19974    }
19975
19976    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
19977        let mut txn = db.begin();
19978        let handle = txn.state_handle();
19979        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
19980        let epoch = txn.commit().unwrap();
19981        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
19982            panic!("expected Committed, got {:?}", handle.state());
19983        };
19984        (epoch, receipt.commit_ts)
19985    }
19986
19987    #[test]
19988    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
19989        let dir = tempfile::tempdir().unwrap();
19990        let db = Database::create(dir.path()).unwrap();
19991        db.create_table("t", int_pk_schema()).unwrap();
19992
19993        let (epoch, commit_ts) = commit_one(&db);
19994        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
19995        // An epoch no commit sealed misses (callers fall back).
19996        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
19997    }
19998
19999    #[test]
20000    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
20001        let dir = tempfile::tempdir().unwrap();
20002        let (epoch, commit_ts) = {
20003            let db = Database::create(dir.path()).unwrap();
20004            db.create_table("t", int_pk_schema()).unwrap();
20005            commit_one(&db)
20006        };
20007
20008        let db = Database::open(dir.path()).unwrap();
20009        let reconstructed = db
20010            .commit_ts_for_epoch(epoch)
20011            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
20012        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
20013        // The ledger byte format stores micros only (spec §8.1): the logical
20014        // counter and tiebreaker reconstruct as 0.
20015        assert_eq!(reconstructed.logical, 0);
20016        assert_eq!(reconstructed.node_tiebreaker, 0);
20017    }
20018
20019    #[test]
20020    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
20021        use crate::wal::Op;
20022        let records = vec![
20023            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
20024            crate::wal::Record::new(
20025                Epoch(2),
20026                7,
20027                Op::TxnCommit {
20028                    epoch: 41,
20029                    added_runs: vec![],
20030                },
20031            ),
20032            // No CommitTimestamp for txn 8: not reconstructible.
20033            crate::wal::Record::new(
20034                Epoch(3),
20035                8,
20036                Op::TxnCommit {
20037                    epoch: 42,
20038                    added_runs: vec![],
20039                },
20040            ),
20041            // Timestamp without a commit marker: aborted, not reconstructible.
20042            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
20043        ];
20044        let ledger = commit_ts_ledger_from_recovery(&records);
20045        assert_eq!(ledger.len(), 1);
20046        assert_eq!(
20047            ledger.get(&41),
20048            Some(&mongreldb_types::hlc::HlcTimestamp {
20049                physical_micros: 1,
20050                logical: 0,
20051                node_tiebreaker: 0,
20052            })
20053        );
20054    }
20055}
20056
20057#[cfg(test)]
20058mod stage2e_storage_mode_tests {
20059    use super::*;
20060    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20061    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
20062    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
20063
20064    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
20065        (
20066            ClusterId::from_bytes([seed; 16]),
20067            NodeId::from_bytes([seed + 1; 16]),
20068            DatabaseId::from_bytes([seed + 2; 16]),
20069        )
20070    }
20071
20072    fn marker(root: &Path) -> Option<StorageMode> {
20073        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
20074        crate::storage_mode::read(&durable).unwrap()
20075    }
20076
20077    fn simple_schema() -> Schema {
20078        Schema {
20079            columns: vec![ColumnDef {
20080                id: 1,
20081                name: "id".into(),
20082                ty: TypeId::Int64,
20083                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20084                default_value: None,
20085                embedding_source: None,
20086            }],
20087            ..Schema::default()
20088        }
20089    }
20090
20091    #[test]
20092    fn standalone_create_writes_marker_and_reopens() {
20093        let dir = tempfile::tempdir().unwrap();
20094        let root = dir.path().join("db");
20095        let db = Database::create(&root).unwrap();
20096        assert_eq!(marker(&root), Some(StorageMode::Standalone));
20097        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
20098        drop(db);
20099        let db = Database::open(&root).unwrap();
20100        assert_eq!(marker(&root), Some(StorageMode::Standalone));
20101        drop(db);
20102    }
20103
20104    #[test]
20105    fn legacy_database_without_marker_opens_and_gains_marker() {
20106        let dir = tempfile::tempdir().unwrap();
20107        let root = dir.path().join("db");
20108        let db = Database::create(&root).unwrap();
20109        drop(db);
20110        // Simulate a pre-marker database.
20111        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
20112        assert_eq!(marker(&root), None);
20113        let db = Database::open(&root).unwrap();
20114        assert_eq!(marker(&root), Some(StorageMode::Standalone));
20115        drop(db);
20116    }
20117
20118    #[test]
20119    fn server_owned_standalone_opens_embedded() {
20120        let dir = tempfile::tempdir().unwrap();
20121        let root = dir.path().join("db");
20122        let db = Database::create(&root).unwrap();
20123        drop(db);
20124        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
20125        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
20126        let db = Database::open(&root).unwrap();
20127        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
20128        drop(db);
20129    }
20130
20131    #[test]
20132    fn cluster_replica_is_rejected_by_normal_opens() {
20133        let dir = tempfile::tempdir().unwrap();
20134        let root = dir.path().join("db");
20135        let (cluster_id, node_id, database_id) = identity(10);
20136        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
20137        assert_eq!(
20138            marker(&root),
20139            Some(StorageMode::ClusterReplica {
20140                cluster_id,
20141                node_id,
20142                database_id,
20143            })
20144        );
20145        drop(db);
20146
20147        let error = Database::open(&root).unwrap_err();
20148        let message = error.to_string();
20149        assert!(
20150            matches!(error, MongrelError::InvalidArgument(_)),
20151            "unexpected error: {message}"
20152        );
20153        assert!(message.contains("cluster node runtime"), "{message}");
20154        assert!(message.contains(&cluster_id.to_hex()), "{message}");
20155        assert!(message.contains(&node_id.to_hex()), "{message}");
20156        assert!(message.contains(&database_id.to_hex()), "{message}");
20157
20158        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
20159        assert!(error.to_string().contains("cluster node runtime"));
20160        // The rejected opens never disturbed the marker.
20161        assert_eq!(
20162            marker(&root),
20163            Some(StorageMode::ClusterReplica {
20164                cluster_id,
20165                node_id,
20166                database_id,
20167            })
20168        );
20169    }
20170
20171    #[test]
20172    fn offline_validation_opens_cluster_replica_read_only() {
20173        let dir = tempfile::tempdir().unwrap();
20174        let root = dir.path().join("db");
20175        let (cluster_id, node_id, database_id) = identity(20);
20176        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
20177        drop(db);
20178
20179        let options = OpenOptions::default().with_offline_validation(true);
20180        let db = Database::open_with_options(&root, options).unwrap();
20181        assert!(db.is_read_only_replica());
20182        let error = db.create_table("t", simple_schema()).unwrap_err();
20183        assert!(matches!(error, MongrelError::ReadOnlyReplica));
20184        drop(db);
20185        // Offline validation leaves the marker exactly as found.
20186        assert_eq!(
20187            marker(&root),
20188            Some(StorageMode::ClusterReplica {
20189                cluster_id,
20190                node_id,
20191                database_id,
20192            })
20193        );
20194    }
20195
20196    #[test]
20197    fn cluster_runtime_open_requires_exact_identity() {
20198        let dir = tempfile::tempdir().unwrap();
20199        let root = dir.path().join("db");
20200        let (cluster_id, node_id, database_id) = identity(30);
20201        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
20202        drop(db);
20203
20204        // A non-ClusterReplica expectation is a caller error.
20205        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
20206        assert!(matches!(error, MongrelError::InvalidArgument(_)));
20207        // Wrong database identity fails closed.
20208        let wrong = StorageMode::ClusterReplica {
20209            cluster_id,
20210            node_id,
20211            database_id: DatabaseId::from_bytes([99; 16]),
20212        };
20213        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
20214        assert!(error.to_string().contains("identity mismatch"), "{error}");
20215        // A legacy database without a marker is not a cluster replica.
20216        let legacy = dir.path().join("legacy");
20217        let legacy_db = Database::create(&legacy).unwrap();
20218        drop(legacy_db);
20219        let expected = StorageMode::ClusterReplica {
20220            cluster_id,
20221            node_id,
20222            database_id,
20223        };
20224        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
20225        assert!(error.to_string().contains("identity mismatch"), "{error}");
20226
20227        // The matching identity opens; user writes are rejected (writes
20228        // arrive through the replicated apply path only).
20229        let db = Database::open_cluster_replica(&root, &expected).unwrap();
20230        assert!(db.is_read_only_replica());
20231        let error = db.create_table("t", simple_schema()).unwrap_err();
20232        assert!(matches!(error, MongrelError::ReadOnlyReplica));
20233        drop(db);
20234    }
20235}
20236
20237#[cfg(test)]
20238mod stage2e_replicated_apply_tests {
20239    use super::*;
20240    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
20241    use crate::memtable::{Row, Value};
20242    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20243    use crate::wal::{Op, Record};
20244    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
20245    use std::sync::Arc;
20246
20247    fn ids() -> (ClusterId, NodeId, DatabaseId) {
20248        (
20249            ClusterId::from_bytes([1; 16]),
20250            NodeId::from_bytes([2; 16]),
20251            DatabaseId::from_bytes([3; 16]),
20252        )
20253    }
20254
20255    fn expected_mode() -> crate::storage_mode::StorageMode {
20256        let (cluster_id, node_id, database_id) = ids();
20257        crate::storage_mode::StorageMode::ClusterReplica {
20258            cluster_id,
20259            node_id,
20260            database_id,
20261        }
20262    }
20263
20264    fn simple_schema() -> Schema {
20265        Schema {
20266            columns: vec![ColumnDef {
20267                id: 1,
20268                name: "id".into(),
20269                ty: TypeId::Int64,
20270                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20271                default_value: None,
20272                embedding_source: None,
20273            }],
20274            ..Schema::default()
20275        }
20276    }
20277
20278    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
20279        CatalogCommandRecord {
20280            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
20281            catalog_version,
20282            command: CatalogCommand::CreateTable {
20283                name: name.to_string(),
20284                schema: simple_schema(),
20285                created_epoch: 1,
20286            },
20287        }
20288    }
20289
20290    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
20291        let rows: Vec<Row> = values
20292            .iter()
20293            .map(|value| {
20294                // Distinct row ids per value so batches never overwrite each
20295                // other's MVCC versions.
20296                Row::new(crate::RowId(*value as u64), Epoch(epoch))
20297                    .with_column(1, Value::Int64(*value))
20298            })
20299            .collect();
20300        vec![
20301            Record::new(
20302                Epoch(0),
20303                txn_id,
20304                Op::Put {
20305                    table_id,
20306                    rows: bincode::serialize(&rows).unwrap(),
20307                },
20308            ),
20309            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
20310            Record::new(
20311                Epoch(0),
20312                txn_id,
20313                Op::TxnCommit {
20314                    epoch,
20315                    added_runs: Vec::new(),
20316                },
20317            ),
20318        ]
20319    }
20320
20321    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
20322        let handle = db.table(table).unwrap();
20323        let rows = handle
20324            .lock()
20325            .visible_rows(crate::epoch::Snapshot::at(Epoch(u64::MAX)))
20326            .unwrap();
20327        let mut values: Vec<i64> = rows
20328            .iter()
20329            .map(|row| match row.columns.get(&1) {
20330                Some(Value::Int64(value)) => *value,
20331                other => panic!("unexpected column: {other:?}"),
20332            })
20333            .collect();
20334        values.sort_unstable();
20335        values
20336    }
20337
20338    #[test]
20339    fn catalog_command_mounts_table_and_replays_as_noop() {
20340        let dir = tempfile::tempdir().unwrap();
20341        let (cluster_id, node_id, database_id) = ids();
20342        let db =
20343            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20344
20345        let record = create_table_record("items", 1);
20346        let delta = db.apply_replicated_catalog_command(&record).unwrap();
20347        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
20348        assert_eq!(db.table_names(), vec!["items".to_string()]);
20349        assert_eq!(db.catalog_version(), 1);
20350
20351        // Idempotent replay of the same record.
20352        let delta = db.apply_replicated_catalog_command(&record).unwrap();
20353        assert!(matches!(delta, CatalogDelta::NoOp));
20354        assert_eq!(db.table_names().len(), 1);
20355        drop(db);
20356
20357        // The command was checkpointed: the table survives reopen.
20358        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
20359        assert_eq!(db.table_names(), vec!["items".to_string()]);
20360        assert_eq!(db.catalog_version(), 1);
20361    }
20362
20363    #[test]
20364    fn records_apply_rows_and_skip_replays_across_restart() {
20365        let dir = tempfile::tempdir().unwrap();
20366        let (cluster_id, node_id, database_id) = ids();
20367        let db =
20368            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20369        db.apply_replicated_catalog_command(&create_table_record("items", 1))
20370            .unwrap();
20371
20372        let records = put_records(1, 0, 2, &[10, 20, 30]);
20373        assert!(db.apply_replicated_records(&records).unwrap());
20374        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
20375        assert_eq!(db.visible_epoch(), Epoch(2));
20376
20377        // Crash-window redelivery of the same committed transaction is a
20378        // side-effect-free replay.
20379        assert!(!db.apply_replicated_records(&records).unwrap());
20380        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
20381
20382        // A later transaction at a higher epoch still applies.
20383        let later = put_records(2, 0, 3, &[40]);
20384        assert!(db.apply_replicated_records(&later).unwrap());
20385        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
20386        let db = Arc::new(db);
20387        db.shutdown().unwrap();
20388
20389        // Restart: the local WAL replays the applied rows, and the state
20390        // machine's redelivery is recognized as a replay — no double-apply.
20391        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
20392        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
20393        assert!(!db.apply_replicated_records(&later).unwrap());
20394        assert!(!db.apply_replicated_records(&records).unwrap());
20395        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
20396    }
20397
20398    #[test]
20399    fn spilled_run_commits_fail_closed_this_wave() {
20400        let dir = tempfile::tempdir().unwrap();
20401        let (cluster_id, node_id, database_id) = ids();
20402        let db =
20403            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20404        db.apply_replicated_catalog_command(&create_table_record("items", 1))
20405            .unwrap();
20406        let mut records = put_records(1, 0, 2, &[10]);
20407        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20408            panic!("put_records ends in TxnCommit");
20409        };
20410        added_runs.push(crate::wal::AddedRun {
20411            table_id: 0,
20412            run_id: 7,
20413            row_count: 1,
20414            level: 0,
20415            min_row_id: 1,
20416            max_row_id: 1,
20417            content_hash: [0; 32],
20418        });
20419        let error = db.apply_replicated_records(&records).unwrap_err();
20420        assert!(
20421            error.to_string().contains("spilled-run"),
20422            "unexpected error: {error}"
20423        );
20424        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
20425    }
20426
20427    #[test]
20428    fn records_without_commit_marker_fail_closed() {
20429        let dir = tempfile::tempdir().unwrap();
20430        let (cluster_id, node_id, database_id) = ids();
20431        let db =
20432            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
20433        db.apply_replicated_catalog_command(&create_table_record("items", 1))
20434            .unwrap();
20435        let mut records = put_records(1, 0, 2, &[10]);
20436        records.pop(); // strip the TxnCommit
20437        let error = db.apply_replicated_records(&records).unwrap_err();
20438        assert!(matches!(error, MongrelError::InvalidArgument(_)));
20439        assert!(db.apply_replicated_records(&[]).is_err());
20440        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
20441    }
20442}
20443
20444#[cfg(test)]
20445mod stage2c_spill_translation_tests {
20446    use super::*;
20447    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
20448    use crate::memtable::{Row, Value};
20449    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20450    use crate::wal::{Op, Record};
20451    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
20452
20453    fn simple_schema() -> Schema {
20454        Schema {
20455            columns: vec![ColumnDef {
20456                id: 1,
20457                name: "id".into(),
20458                ty: TypeId::Int64,
20459                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20460                default_value: None,
20461                embedding_source: None,
20462            }],
20463            ..Schema::default()
20464        }
20465    }
20466
20467    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
20468        CatalogCommandRecord {
20469            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
20470            catalog_version,
20471            command: CatalogCommand::CreateTable {
20472                name: name.to_string(),
20473                schema: simple_schema(),
20474                created_epoch: 1,
20475            },
20476        }
20477    }
20478
20479    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
20480        let handle = db.table(table).unwrap();
20481        let rows = handle
20482            .lock()
20483            .visible_rows(crate::epoch::Snapshot::at(Epoch(u64::MAX)))
20484            .unwrap();
20485        let mut values: Vec<i64> = rows
20486            .iter()
20487            .map(|row| match row.columns.get(&1) {
20488                Some(Value::Int64(value)) => *value,
20489                other => panic!("unexpected column: {other:?}"),
20490            })
20491            .collect();
20492        values.sort_unstable();
20493        values
20494    }
20495
20496    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
20497        let rows: Vec<Row> = values
20498            .iter()
20499            .map(|value| {
20500                Row::new(crate::RowId(*value as u64), Epoch(epoch))
20501                    .with_column(1, Value::Int64(*value))
20502            })
20503            .collect();
20504        vec![
20505            Record::new(
20506                Epoch(0),
20507                txn_id,
20508                Op::Put {
20509                    table_id,
20510                    rows: bincode::serialize(&rows).unwrap(),
20511                },
20512            ),
20513            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
20514            Record::new(
20515                Epoch(0),
20516                txn_id,
20517                Op::TxnCommit {
20518                    epoch,
20519                    added_runs: Vec::new(),
20520                },
20521            ),
20522        ]
20523    }
20524
20525    fn added_run(
20526        table_id: u64,
20527        row_count: u64,
20528        min_row_id: u64,
20529        max_row_id: u64,
20530    ) -> crate::wal::AddedRun {
20531        crate::wal::AddedRun {
20532            table_id,
20533            run_id: 7,
20534            row_count,
20535            level: 0,
20536            min_row_id,
20537            max_row_id,
20538            content_hash: [0; 32],
20539        }
20540    }
20541
20542    /// Replays the shared WAL of `db` and returns every record of the one
20543    /// transaction whose commit marker links spilled runs.
20544    fn spilled_commit_records(db: &Database) -> Vec<Record> {
20545        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
20546        let txn_id = records
20547            .iter()
20548            .find_map(|record| match &record.op {
20549                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
20550                _ => None,
20551            })
20552            .expect("a spilled commit is present in the WAL");
20553        records
20554            .into_iter()
20555            .filter(|record| record.txn_id == txn_id)
20556            .collect()
20557    }
20558
20559    #[test]
20560    fn non_spilled_records_translate_byte_identical() {
20561        let records = put_records(1, 0, 2, &[10, 20, 30]);
20562        let translated = translate_records_for_replication(&records).unwrap();
20563        assert_eq!(
20564            bincode::serialize(&translated).unwrap(),
20565            bincode::serialize(&records).unwrap(),
20566            "a commit without spill links must pass through byte-identical"
20567        );
20568    }
20569
20570    #[test]
20571    fn translation_rejects_uncovered_or_malformed_spills() {
20572        // added_runs with no logical spill records at all: rejected.
20573        let mut records = put_records(1, 0, 2, &[10]);
20574        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20575            panic!("put_records ends in TxnCommit");
20576        };
20577        added_runs.push(added_run(0, 1, 10, 10));
20578        let error = translate_records_for_replication(&records).unwrap_err();
20579        assert!(
20580            error.to_string().contains("no logical row records"),
20581            "unexpected error: {error}"
20582        );
20583
20584        // Coverage present but short of the linked row count: rejected.
20585        let mut records = put_records(1, 0, 2, &[10]);
20586        let spilled: Vec<Row> = (0..3_u64)
20587            .map(|value| {
20588                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
20589            })
20590            .collect();
20591        records.insert(
20592            0,
20593            Record::new(
20594                Epoch(0),
20595                1,
20596                Op::SpilledRows {
20597                    table_id: 0,
20598                    rows: bincode::serialize(&spilled).unwrap(),
20599                },
20600            ),
20601        );
20602        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20603            panic!("put_records ends in TxnCommit");
20604        };
20605        added_runs.push(added_run(0, 4, 0, 3));
20606        let error = translate_records_for_replication(&records).unwrap_err();
20607        assert!(
20608            error.to_string().contains("cover 3 rows"),
20609            "unexpected error: {error}"
20610        );
20611
20612        // An undecodable spill payload: rejected at propose time, never at apply.
20613        let mut records = put_records(1, 0, 2, &[10]);
20614        records.insert(
20615            0,
20616            Record::new(
20617                Epoch(0),
20618                1,
20619                Op::SpilledRows {
20620                    table_id: 0,
20621                    rows: vec![0xFF, 0x01, 0x02],
20622                },
20623            ),
20624        );
20625        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
20626            panic!("put_records ends in TxnCommit");
20627        };
20628        added_runs.push(added_run(0, 1, 0, 0));
20629        assert!(translate_records_for_replication(&records).is_err());
20630
20631        // Structural violations mirror the apply-side contract.
20632        assert!(translate_records_for_replication(&[]).is_err());
20633        let mut mixed = put_records(1, 0, 2, &[10]);
20634        mixed[0].txn_id = 99;
20635        assert!(translate_records_for_replication(&mixed).is_err());
20636        let mut no_commit = put_records(1, 0, 2, &[10]);
20637        no_commit.pop();
20638        assert!(translate_records_for_replication(&no_commit).is_err());
20639    }
20640
20641    #[test]
20642    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
20643        // A real standalone commit that spills (spec section 8.5).
20644        let leader_dir = tempfile::tempdir().unwrap();
20645        let leader = Database::create(leader_dir.path()).unwrap();
20646        leader.create_table("t", simple_schema()).unwrap();
20647        leader.set_spill_threshold(1);
20648        let table_id = leader.table_id("t").unwrap();
20649        let values: Vec<i64> = (0..60).collect();
20650        leader
20651            .transaction(|txn| {
20652                for value in &values {
20653                    txn.put("t", vec![(1, Value::Int64(*value))])?;
20654                }
20655                Ok(())
20656            })
20657            .unwrap();
20658        assert_eq!(visible_ids(&leader, "t"), values);
20659
20660        // The leader's own WAL keeps the spill shape: SpilledRows records
20661        // plus an added_runs commit marker.
20662        let records = spilled_commit_records(&leader);
20663        assert!(records
20664            .iter()
20665            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
20666        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
20667            panic!("a commit sequence ends in TxnCommit");
20668        };
20669        assert!(!added_runs.is_empty());
20670        let commit_epoch = *epoch;
20671
20672        // Translation strips every run reference and keeps the rows as
20673        // logical puts; the input sequence is untouched.
20674        let translated = translate_records_for_replication(&records).unwrap();
20675        assert!(records
20676            .iter()
20677            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
20678        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
20679            panic!("a commit sequence ends in TxnCommit");
20680        };
20681        assert!(!added_runs.is_empty(), "input records must be unchanged");
20682        assert_eq!(translated.len(), records.len());
20683        assert!(translated
20684            .iter()
20685            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
20686        assert!(translated
20687            .iter()
20688            .any(|record| matches!(record.op, Op::Put { .. })));
20689        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
20690            panic!("a commit sequence ends in TxnCommit");
20691        };
20692        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
20693        assert_eq!(*epoch, commit_epoch);
20694
20695        // The translated payload applies on a replica with identical rows.
20696        let replica_dir = tempfile::tempdir().unwrap();
20697        let replica = Database::create_cluster_replica(
20698            replica_dir.path(),
20699            ClusterId::from_bytes([1; 16]),
20700            NodeId::from_bytes([2; 16]),
20701            DatabaseId::from_bytes([3; 16]),
20702        )
20703        .unwrap();
20704        replica
20705            .apply_replicated_catalog_command(&create_table_record("t", 1))
20706            .unwrap();
20707        assert_eq!(replica.table_id("t").unwrap(), table_id);
20708        assert!(replica.apply_replicated_records(&translated).unwrap());
20709        assert_eq!(visible_ids(&replica, "t"), values);
20710        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
20711
20712        // Standalone behavior is unchanged: the leader still recovers its
20713        // spilled commit by linking the run file.
20714        drop(leader);
20715        let leader = Database::open(leader_dir.path()).unwrap();
20716        assert_eq!(visible_ids(&leader, "t"), values);
20717    }
20718
20719    #[test]
20720    fn staged_txn_writes_validate_and_apply() {
20721        let dir = tempfile::tempdir().unwrap();
20722        let db = Database::create_cluster_replica(
20723            dir.path(),
20724            ClusterId::from_bytes([1; 16]),
20725            NodeId::from_bytes([2; 16]),
20726            DatabaseId::from_bytes([3; 16]),
20727        )
20728        .unwrap();
20729        db.apply_replicated_catalog_command(&create_table_record("t", 1))
20730            .unwrap();
20731        let table_id = db.table_id("t").unwrap();
20732
20733        // Malformed payloads and unmounted tables are rejected at prepare.
20734        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
20735        let unknown_table = StagedTxnWrite::Put {
20736            table_id: 99,
20737            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
20738        }
20739        .encode()
20740        .unwrap();
20741        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
20742        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
20743            .iter()
20744            .map(|value| {
20745                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
20746                    .with_column(1, Value::Int64(*value))];
20747                StagedTxnWrite::Put {
20748                    table_id,
20749                    rows: bincode::serialize(&rows).unwrap(),
20750                }
20751                .encode()
20752                .unwrap()
20753            })
20754            .collect();
20755        db.validate_staged_txn_writes(&good).unwrap();
20756
20757        // A committed resolution applies the staged writes; a delete
20758        // resolution removes them; both are replay-safe.
20759        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
20760            physical_micros: 5_000,
20761            logical: 0,
20762            node_tiebreaker: 0,
20763        };
20764        assert!(db
20765            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
20766            .unwrap());
20767        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
20768        let delete = StagedTxnWrite::Delete {
20769            table_id,
20770            row_ids: vec![20],
20771        }
20772        .encode()
20773        .unwrap();
20774        assert!(db
20775            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
20776            .unwrap());
20777        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
20778
20779        // Restart: the synthetic WAL transactions replay through the same
20780        // recovery path; the rows are durable.
20781        let db = Arc::new(db);
20782        db.shutdown().unwrap();
20783        let expected = crate::storage_mode::StorageMode::ClusterReplica {
20784            cluster_id: ClusterId::from_bytes([1; 16]),
20785            node_id: NodeId::from_bytes([2; 16]),
20786            database_id: DatabaseId::from_bytes([3; 16]),
20787        };
20788        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
20789        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
20790    }
20791}