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
9// Online secondary-index DDL (create / drop / replace) as a child module so it
10// can reach private `DatabaseCore` fields while keeping the public surface on
11// [`Database`].
12#[path = "index_ddl.rs"]
13mod index_ddl;
14
15use crate::catalog::{self, Catalog, CatalogEntry, TableState, META_DEK_LEN};
16use crate::engine::{SharedCtx, Table};
17use crate::epoch::{Epoch, EpochAuthority, EpochGuard, MaintenanceReceipt, Snapshot};
18use crate::error::{MongrelError, Result};
19use crate::external_table::ExternalTableEntry;
20use crate::memtable::Value;
21use crate::procedure::{
22    ProcedureCallOutput, ProcedureCallResult, ProcedureCallRow, ProcedureCondition, ProcedureStep,
23    ProcedureValue, StoredProcedure,
24};
25use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
26use crate::rowid::RowId;
27use crate::schema::{AlterColumn, ColumnDef, ColumnFlags, Schema, TypeId};
28use crate::trigger::{
29    StoredTrigger, TriggerCondition, TriggerConfig, TriggerEntry, TriggerEvent, TriggerExpr,
30    TriggerRaiseAction, TriggerStep, TriggerTarget, TriggerTiming, TriggerValue,
31};
32use parking_lot::{Mutex, RwLock};
33use sha2::{Digest, Sha256};
34use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
35use std::io::{Read, Write};
36use std::path::{Path, PathBuf};
37use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
38use std::sync::Arc;
39use subtle::ConstantTimeEq;
40
41pub const TABLES_DIR: &str = "tables";
42pub const VTAB_DIR: &str = "_vtab";
43pub const META_DIR: &str = "_meta";
44pub const KEYS_FILENAME: &str = "keys";
45pub const KMS_KEY_FILENAME: &str = "kms_key.json";
46const MAX_KMS_KEY_ENVELOPE_BYTES: usize = 1024 * 1024;
47pub const HISTORY_RETENTION_FILENAME: &str = "history_retention";
48pub const CTAS_BUILD_TABLE_PREFIX: &str = "__mongreldb_ctas_build_";
49
50/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
51/// than any table. `u64::MAX` is never allocated to a real table (the catalog
52/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
53pub const WAL_TABLE_ID: u64 = u64::MAX;
54/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
55/// state instead of an ordinary table.
56pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
57
58fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
59    catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
60        MongrelError::Conflict("security catalog version space is exhausted".into())
61    })?;
62    Ok(())
63}
64
65type OpenLeaseId = u64;
66
67static DATABASE_OPEN_WAIT_COUNT: AtomicU64 = AtomicU64::new(0);
68static DATABASE_OPEN_FAILURE_COUNT: AtomicU64 = AtomicU64::new(0);
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub struct DatabaseOpenMetrics {
72    pub lock_waits: u64,
73    pub failures: u64,
74}
75
76#[derive(Clone, Debug, Eq, Hash, PartialEq)]
77enum DatabaseOpenKey {
78    IntendedPath(PathBuf),
79    FileIdentity(crate::durable_file::DurableFileIdentity),
80}
81
82#[derive(Debug)]
83enum ProcessOpenState {
84    Opening { lease_id: OpenLeaseId },
85    Open { lease_id: OpenLeaseId },
86    Closing { lease_id: OpenLeaseId },
87}
88
89impl ProcessOpenState {
90    fn lease_id(&self) -> OpenLeaseId {
91        match self {
92            Self::Opening { lease_id } | Self::Open { lease_id } | Self::Closing { lease_id } => {
93                *lease_id
94            }
95        }
96    }
97}
98
99fn embedding_error(error: crate::embedding::EmbeddingError) -> MongrelError {
100    match error {
101        crate::embedding::EmbeddingError::LimitExceeded {
102            resource,
103            requested,
104            limit,
105        } => MongrelError::ResourceLimitExceeded {
106            resource,
107            requested,
108            limit,
109        },
110        crate::embedding::EmbeddingError::Execution(message) => {
111            if message.contains("deadline") {
112                MongrelError::DeadlineExceeded
113            } else {
114                MongrelError::Cancelled
115            }
116        }
117        error => MongrelError::Other(error.to_string()),
118    }
119}
120
121fn render_embedding_input(
122    schema: &Schema,
123    spec: &crate::embedding::GeneratedEmbeddingSpec,
124    cells: &[(u16, crate::memtable::Value)],
125) -> Result<String> {
126    let mut values = Vec::with_capacity(spec.source_columns.len());
127    for source_id in &spec.source_columns {
128        let column = schema
129            .columns
130            .iter()
131            .find(|column| column.id == *source_id)
132            .ok_or_else(|| MongrelError::Schema(format!("unknown embedding source {source_id}")))?;
133        let value = cells
134            .iter()
135            .find(|(id, _)| id == source_id)
136            .map(|(_, value)| embedding_input_value(value))
137            .transpose()?
138            .unwrap_or_default();
139        values.push((column.name.as_str(), value));
140    }
141    if spec.input_template.is_empty() {
142        return Ok(values
143            .into_iter()
144            .map(|(_, value)| value)
145            .collect::<Vec<_>>()
146            .join("\n"));
147    }
148    let mut rendered = spec.input_template.clone();
149    for (name, value) in values {
150        rendered = rendered.replace(&format!("{{{name}}}"), &value);
151    }
152    if rendered.contains('{') || rendered.contains('}') {
153        return Err(MongrelError::Schema(
154            "embedding input template contains an unknown placeholder".into(),
155        ));
156    }
157    Ok(rendered)
158}
159
160fn embedding_input_value(value: &crate::memtable::Value) -> Result<String> {
161    use crate::memtable::Value;
162    match value {
163        Value::Null => Ok(String::new()),
164        Value::Bool(value) => Ok(value.to_string()),
165        Value::Int64(value) => Ok(value.to_string()),
166        Value::Float64(value) => Ok(value.to_string()),
167        Value::Bytes(value) | Value::Json(value) => String::from_utf8(value.clone())
168            .map_err(|_| MongrelError::InvalidArgument("embedding source is not UTF-8".into())),
169        Value::Decimal(value) => Ok(value.to_string()),
170        Value::Interval {
171            months,
172            days,
173            nanos,
174        } => Ok(format!("{months}:{days}:{nanos}")),
175        Value::Uuid(value) => Ok(value.iter().map(|byte| format!("{byte:02x}")).collect()),
176        Value::Embedding(_) | Value::GeneratedEmbedding(_) => Err(MongrelError::InvalidArgument(
177            "embedding columns cannot be embedding text sources".into(),
178        )),
179    }
180}
181
182#[derive(Default)]
183struct ProcessOpenRegistry {
184    next_lease_id: OpenLeaseId,
185    entries: HashMap<DatabaseOpenKey, ProcessOpenState>,
186}
187
188fn process_open_registry() -> &'static Mutex<ProcessOpenRegistry> {
189    static REGISTRY: std::sync::OnceLock<Mutex<ProcessOpenRegistry>> = std::sync::OnceLock::new();
190    REGISTRY.get_or_init(|| Mutex::new(ProcessOpenRegistry::default()))
191}
192
193fn same_process_locked(path: &Path) -> MongrelError {
194    MongrelError::DatabaseLocked {
195        path: path.to_path_buf(),
196        message: "database is already open in this process; reuse the existing Arc<Database>"
197            .into(),
198    }
199}
200
201struct OpenReservation {
202    lease_id: OpenLeaseId,
203    keys: Vec<DatabaseOpenKey>,
204    committed: bool,
205}
206
207impl OpenReservation {
208    fn acquire(key: DatabaseOpenKey, display_path: &Path) -> Result<Self> {
209        let mut registry = process_open_registry().lock();
210        if registry.entries.contains_key(&key) {
211            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
212            return Err(same_process_locked(display_path));
213        }
214        registry.next_lease_id = registry.next_lease_id.checked_add(1).ok_or_else(|| {
215            MongrelError::Full("process database-open lease namespace exhausted".into())
216        })?;
217        let lease_id = registry.next_lease_id;
218        registry
219            .entries
220            .insert(key.clone(), ProcessOpenState::Opening { lease_id });
221        Ok(Self {
222            lease_id,
223            keys: vec![key],
224            committed: false,
225        })
226    }
227
228    fn into_lease(
229        mut self,
230        bootstrap_file: std::fs::File,
231        canonical_path: PathBuf,
232    ) -> ExclusiveDatabaseLease {
233        self.committed = true;
234        ExclusiveDatabaseLease {
235            lease_id: self.lease_id,
236            keys: std::mem::take(&mut self.keys),
237            bootstrap_file,
238            legacy_file: None,
239            canonical_path,
240            durable_root: None,
241            owner_pid: std::process::id(),
242            opened: false,
243        }
244    }
245}
246
247impl Drop for OpenReservation {
248    fn drop(&mut self) {
249        if self.committed {
250            return;
251        }
252        DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
253        let mut registry = process_open_registry().lock();
254        for key in &self.keys {
255            if registry
256                .entries
257                .get(key)
258                .is_some_and(|state| state.lease_id() == self.lease_id)
259            {
260                registry.entries.remove(key);
261            }
262        }
263    }
264}
265
266struct ExclusiveDatabaseLease {
267    lease_id: OpenLeaseId,
268    keys: Vec<DatabaseOpenKey>,
269    bootstrap_file: std::fs::File,
270    legacy_file: Option<std::fs::File>,
271    canonical_path: PathBuf,
272    durable_root: Option<Arc<crate::durable_file::DurableRoot>>,
273    owner_pid: u32,
274    opened: bool,
275}
276
277impl ExclusiveDatabaseLease {
278    fn claim_root_identity(&mut self, root: &crate::durable_file::DurableRoot) -> Result<()> {
279        let key = DatabaseOpenKey::FileIdentity(root.file_identity()?);
280        if self.keys.contains(&key) {
281            return Ok(());
282        }
283        let mut registry = process_open_registry().lock();
284        if registry.entries.contains_key(&key) {
285            return Err(same_process_locked(&self.canonical_path));
286        }
287        registry.entries.insert(
288            key.clone(),
289            ProcessOpenState::Opening {
290                lease_id: self.lease_id,
291            },
292        );
293        self.keys.push(key);
294        Ok(())
295    }
296
297    fn mark_open(&mut self) -> Result<()> {
298        let mut registry = process_open_registry().lock();
299        if self.keys.iter().any(|key| {
300            registry
301                .entries
302                .get(key)
303                .is_none_or(|state| state.lease_id() != self.lease_id)
304        }) {
305            return Err(MongrelError::Conflict(
306                "database-open reservation changed during initialization".into(),
307            ));
308        }
309        for key in &self.keys {
310            registry.entries.insert(
311                key.clone(),
312                ProcessOpenState::Open {
313                    lease_id: self.lease_id,
314                },
315            );
316        }
317        self.opened = true;
318        Ok(())
319    }
320}
321
322impl Drop for ExclusiveDatabaseLease {
323    fn drop(&mut self) {
324        if std::process::id() != self.owner_pid {
325            return;
326        }
327        if !self.opened {
328            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
329        }
330        {
331            let mut registry = process_open_registry().lock();
332            for key in &self.keys {
333                if registry
334                    .entries
335                    .get(key)
336                    .is_some_and(|state| state.lease_id() == self.lease_id)
337                {
338                    registry.entries.insert(
339                        key.clone(),
340                        ProcessOpenState::Closing {
341                            lease_id: self.lease_id,
342                        },
343                    );
344                }
345            }
346        }
347        if let Some(file) = &self.legacy_file {
348            let _ = fs2::FileExt::unlock(file);
349        }
350        let _ = fs2::FileExt::unlock(&self.bootstrap_file);
351        let mut registry = process_open_registry().lock();
352        for key in &self.keys {
353            if registry
354                .entries
355                .get(key)
356                .is_some_and(|state| state.lease_id() == self.lease_id)
357            {
358                registry.entries.remove(key);
359            }
360        }
361    }
362}
363
364fn commit_prepare_checkpoint(
365    control: Option<&crate::ExecutionControl>,
366    index: usize,
367) -> Result<()> {
368    if index.is_multiple_of(256) {
369        if let Some(control) = control {
370            control.checkpoint()?;
371        }
372    }
373    Ok(())
374}
375
376fn finish_controlled_commit_attempt(
377    result: Result<Epoch>,
378    after_commit: &mut Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
379) -> Result<Epoch> {
380    let Some(after_commit) = after_commit.as_mut() else {
381        return result;
382    };
383    match result {
384        Ok(epoch) => match (**after_commit)(Some(epoch)) {
385            Ok(()) => Ok(epoch),
386            Err(error) => Err(MongrelError::DurableCommit {
387                epoch: epoch.0,
388                message: error.to_string(),
389            }),
390        },
391        Err(MongrelError::DurableCommit { epoch, message }) => {
392            let callback_error = (**after_commit)(Some(Epoch(epoch))).err();
393            Err(MongrelError::DurableCommit {
394                epoch,
395                message: callback_error
396                    .map(|error| format!("{message}; commit callback: {error}"))
397                    .unwrap_or(message),
398            })
399        }
400        Err(error) => match (**after_commit)(None) {
401            Ok(()) => Err(error),
402            Err(callback_error) => Err(MongrelError::Other(format!(
403                "{error}; commit callback: {callback_error}"
404            ))),
405        },
406    }
407}
408
409fn current_unix_nanos() -> u64 {
410    std::time::SystemTime::now()
411        .duration_since(std::time::UNIX_EPOCH)
412        .unwrap_or_default()
413        .as_nanos() as u64
414}
415
416fn read_encryption_salt(
417    root: &crate::durable_file::DurableRoot,
418) -> Result<[u8; crate::encryption::SALT_LEN]> {
419    let mut file = root
420        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
421        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
422    let length = file.metadata()?.len();
423    if length != crate::encryption::SALT_LEN as u64 {
424        return Err(MongrelError::Encryption(format!(
425            "invalid encryption salt length: got {length}, expected {}",
426            crate::encryption::SALT_LEN
427        )));
428    }
429    let mut salt = [0_u8; crate::encryption::SALT_LEN];
430    file.read_exact(&mut salt)?;
431    Ok(salt)
432}
433
434fn read_kms_key_envelope(
435    root: &crate::durable_file::DurableRoot,
436) -> Result<crate::security_hardening::KmsDatabaseKeyEnvelope> {
437    let file = root
438        .open_regular(Path::new(META_DIR).join(KMS_KEY_FILENAME))
439        .map_err(|error| MongrelError::NotFound(format!("KMS key envelope: {error}")))?;
440    let mut bytes = Vec::new();
441    file.take((MAX_KMS_KEY_ENVELOPE_BYTES + 1) as u64)
442        .read_to_end(&mut bytes)?;
443    if bytes.len() > MAX_KMS_KEY_ENVELOPE_BYTES {
444        return Err(MongrelError::Encryption(
445            "KMS key envelope exceeds 1 MiB".into(),
446        ));
447    }
448    serde_json::from_slice(&bytes)
449        .map_err(|error| MongrelError::Encryption(format!("invalid KMS key envelope: {error}")))
450}
451
452fn write_kms_key_envelope(
453    root: &Path,
454    envelope: &crate::security_hardening::KmsDatabaseKeyEnvelope,
455) -> Result<()> {
456    let bytes = serde_json::to_vec(envelope)
457        .map_err(|error| MongrelError::Encryption(format!("encode KMS key envelope: {error}")))?;
458    if bytes.len() > MAX_KMS_KEY_ENVELOPE_BYTES {
459        return Err(MongrelError::Encryption(
460            "KMS key envelope exceeds 1 MiB".into(),
461        ));
462    }
463    Ok(crate::durable_file::write_atomic(
464        &root.join(META_DIR).join(KMS_KEY_FILENAME),
465        &bytes,
466    )?)
467}
468
469fn unwrap_kms_database_key(
470    provider: &dyn crate::security_hardening::KeyManagementProvider,
471    envelope: &crate::security_hardening::KmsDatabaseKeyEnvelope,
472) -> Result<zeroize::Zeroizing<Vec<u8>>> {
473    if provider.provider_id() != envelope.provider_id {
474        return Err(MongrelError::Encryption(format!(
475            "KMS provider mismatch: database requires {:?}, got {:?}",
476            envelope.provider_id,
477            provider.provider_id()
478        )));
479    }
480    let key = provider
481        .unwrap_key(&envelope.wrapped_key)
482        .map_err(|error| MongrelError::Encryption(error.to_string()))?;
483    if key.len() != crate::encryption::DEK_LEN {
484        return Err(MongrelError::Encryption(format!(
485            "KMS returned {} key bytes, expected {}",
486            key.len(),
487            crate::encryption::DEK_LEN
488        )));
489    }
490    Ok(key)
491}
492
493fn create_kms_kek(
494    root: &Path,
495    provider: &dyn crate::security_hardening::KeyManagementProvider,
496    kms_key_id: &str,
497) -> Result<Arc<crate::encryption::Kek>> {
498    if kms_key_id.is_empty() {
499        return Err(MongrelError::InvalidArgument(
500            "KMS key id must not be empty".into(),
501        ));
502    }
503    let salt = crate::encryption::random_salt()?;
504    let mut raw_key = zeroize::Zeroizing::new([0_u8; crate::encryption::DEK_LEN]);
505    crate::encryption::fill_random(raw_key.as_mut())?;
506    let wrapped_key = provider
507        .wrap_key(kms_key_id, raw_key.as_ref())
508        .map_err(|error| MongrelError::Encryption(error.to_string()))?;
509    let envelope = crate::security_hardening::KmsDatabaseKeyEnvelope {
510        provider_id: provider.provider_id().to_owned(),
511        wrapped_key,
512    };
513    crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
514    write_kms_key_envelope(root, &envelope)?;
515    Ok(Arc::new(crate::encryption::Kek::from_raw_key(
516        raw_key.as_ref(),
517        &salt,
518    )?))
519}
520
521fn open_kms_kek(
522    root: &crate::durable_file::DurableRoot,
523    provider: &dyn crate::security_hardening::KeyManagementProvider,
524) -> Result<Arc<crate::encryption::Kek>> {
525    let salt = read_encryption_salt(root)?;
526    let envelope = read_kms_key_envelope(root)?;
527    let raw_key = unwrap_kms_database_key(provider, &envelope)?;
528    Ok(Arc::new(crate::encryption::Kek::from_raw_key(
529        raw_key.as_ref(),
530        &salt,
531    )?))
532}
533
534fn persist_key_rotation(
535    journal: &crate::security_hardening::KeyRotationJournal,
536    record: &crate::security_hardening::KeyRotationRecord,
537) -> Result<()> {
538    journal
539        .persist(record)
540        .map_err(|error| MongrelError::Encryption(error.to_string()))
541}
542
543fn fail_key_rotation<T>(
544    journal: &crate::security_hardening::KeyRotationJournal,
545    record: &mut crate::security_hardening::KeyRotationRecord,
546    error: impl ToString,
547) -> Result<T> {
548    let message = error.to_string();
549    record.fail(&message);
550    persist_key_rotation(journal, record)?;
551    Err(MongrelError::Encryption(message))
552}
553
554fn advance_key_rotation(
555    journal: &crate::security_hardening::KeyRotationJournal,
556    record: &mut crate::security_hardening::KeyRotationRecord,
557) -> Result<()> {
558    let now = std::time::SystemTime::now()
559        .duration_since(std::time::UNIX_EPOCH)
560        .unwrap_or_default()
561        .as_micros() as u64;
562    record
563        .advance(now)
564        .map_err(|error| MongrelError::Encryption(error.to_string()))?;
565    persist_key_rotation(journal, record)?;
566    let hook = match record.phase {
567        crate::security_hardening::KeyRotationPhase::WrappingNewKey => "kms.rotation.phase.1",
568        crate::security_hardening::KeyRotationPhase::DualRead => "kms.rotation.phase.2",
569        crate::security_hardening::KeyRotationPhase::Reencrypting => "kms.rotation.phase.3",
570        crate::security_hardening::KeyRotationPhase::Validating => "kms.rotation.phase.4",
571        crate::security_hardening::KeyRotationPhase::Published => "kms.rotation.phase.5",
572        crate::security_hardening::KeyRotationPhase::RetiringOldKey => "kms.rotation.phase.6",
573        crate::security_hardening::KeyRotationPhase::Succeeded => "kms.rotation.phase.7",
574        crate::security_hardening::KeyRotationPhase::Pending
575        | crate::security_hardening::KeyRotationPhase::Failed => return Ok(()),
576    };
577    mongreldb_fault::inject(hook).map_err(|error| MongrelError::Encryption(error.to_string()))
578}
579
580fn incremental_aggregate_cache_key(
581    table: &str,
582    conditions: &[crate::query::Condition],
583    column: Option<u16>,
584    agg: crate::engine::NativeAgg,
585    principal: Option<&crate::auth::Principal>,
586    security_version: u64,
587) -> u64 {
588    use std::hash::{Hash, Hasher};
589    let projection = column.as_ref().map(std::slice::from_ref);
590    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
591    let mut hasher = std::collections::hash_map::DefaultHasher::new();
592    table.hash(&mut hasher);
593    query_key.hash(&mut hasher);
594    match agg {
595        crate::engine::NativeAgg::Count => 0u8,
596        crate::engine::NativeAgg::Sum => 1,
597        crate::engine::NativeAgg::Min => 2,
598        crate::engine::NativeAgg::Max => 3,
599        crate::engine::NativeAgg::Avg => 4,
600    }
601    .hash(&mut hasher);
602    if let Some(principal) = principal {
603        principal.user_id.hash(&mut hasher);
604        principal.created_epoch.hash(&mut hasher);
605        principal.username.hash(&mut hasher);
606        principal.is_admin.hash(&mut hasher);
607        let mut roles = principal.roles.clone();
608        roles.sort_unstable();
609        roles.hash(&mut hasher);
610    }
611    hasher.finish()
612}
613
614fn read_history_retention(
615    root: &crate::durable_file::DurableRoot,
616    current_epoch: Epoch,
617) -> Result<(u64, Epoch)> {
618    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
619    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
620        Ok(file) => file,
621        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
622            return Ok((0, current_epoch));
623        }
624        Err(error) => return Err(error.into()),
625    };
626    let length = file.metadata()?.len();
627    if length > MAX_HISTORY_RETENTION_BYTES {
628        return Err(MongrelError::ResourceLimitExceeded {
629            resource: "history retention bytes",
630            requested: usize::try_from(length).unwrap_or(usize::MAX),
631            limit: MAX_HISTORY_RETENTION_BYTES as usize,
632        });
633    }
634    let mut bytes = Vec::with_capacity(length as usize);
635    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
636        .read_to_end(&mut bytes)?;
637    if bytes.len() as u64 != length {
638        return Err(MongrelError::Other(
639            "history retention length changed while reading".into(),
640        ));
641    }
642    let text = std::str::from_utf8(&bytes)
643        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
644    let mut fields = text.split_whitespace();
645    let epochs = fields
646        .next()
647        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
648        .parse::<u64>()
649        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
650    let start = fields
651        .next()
652        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
653        .parse::<u64>()
654        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
655    if fields.next().is_some() || start > current_epoch.0 {
656        return Err(MongrelError::Other(
657            "history retention file has trailing fields or a future start epoch".into(),
658        ));
659    }
660    Ok((epochs, Epoch(start)))
661}
662
663fn write_history_retention<F>(
664    root: &Path,
665    epochs: u64,
666    start: Epoch,
667    after_publish: F,
668) -> Result<()>
669where
670    F: FnOnce(),
671{
672    let meta = root.join(META_DIR);
673    let path = meta.join(HISTORY_RETENTION_FILENAME);
674    let bytes = format!("{epochs} {}\n", start.0);
675    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
676    Ok(())
677}
678
679struct PreparedBackupDestination {
680    parent: crate::durable_file::DurableRoot,
681    destination_name: std::ffi::OsString,
682    destination_path: PathBuf,
683    stage_name: std::ffi::OsString,
684    stage: Option<Box<crate::durable_file::DurableRoot>>,
685}
686
687fn prepare_backup_destination(
688    source: &Path,
689    destination: &Path,
690) -> Result<PreparedBackupDestination> {
691    let destination_name = destination
692        .file_name()
693        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
694        .to_os_string();
695    let requested_parent = destination
696        .parent()
697        .filter(|path| !path.as_os_str().is_empty())
698        .unwrap_or_else(|| Path::new("."));
699    crate::durable_file::create_directory_all(requested_parent)?;
700    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
701    prepare_backup_destination_in(source, &parent, &destination_name)
702}
703
704fn prepare_backup_destination_in(
705    source: &Path,
706    parent: &crate::durable_file::DurableRoot,
707    destination_name: &std::ffi::OsStr,
708) -> Result<PreparedBackupDestination> {
709    let source = source.canonicalize()?;
710    if parent.canonical_path().starts_with(&source) {
711        return Err(MongrelError::InvalidArgument(
712            "backup destination must not be inside the source database".into(),
713        ));
714    }
715    if parent.entry_exists(Path::new(&destination_name))? {
716        return Err(MongrelError::Conflict(format!(
717            "backup destination already exists: {}",
718            parent.canonical_path().join(destination_name).display()
719        )));
720    }
721    let mut stage_name = None;
722    for _ in 0..128 {
723        let mut nonce = [0_u8; 8];
724        crate::encryption::fill_random(&mut nonce)?;
725        let suffix = nonce
726            .iter()
727            .map(|byte| format!("{byte:02x}"))
728            .collect::<String>();
729        let name = std::ffi::OsString::from(format!(
730            ".{}.backup-stage-{}-{suffix}",
731            destination_name.to_string_lossy(),
732            std::process::id()
733        ));
734        match parent.create_directory_new(Path::new(&name)) {
735            Ok(()) => {
736                stage_name = Some(name);
737                break;
738            }
739            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
740            Err(error) => return Err(error.into()),
741        }
742    }
743    let stage_name = stage_name
744        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
745    let stage = parent.open_directory(Path::new(&stage_name))?;
746    Ok(PreparedBackupDestination {
747        destination_path: parent.canonical_path().join(destination_name),
748        destination_name: destination_name.to_os_string(),
749        stage_name,
750        stage: Some(Box::new(stage)),
751        parent: parent.try_clone()?,
752    })
753}
754
755fn copy_backup_boundary(
756    source_root: &Path,
757    destination_root: &crate::durable_file::DurableRoot,
758    deferred_runs: &HashSet<PathBuf>,
759    copied: &mut Vec<PathBuf>,
760    control: Option<&crate::ExecutionControl>,
761) -> Result<()> {
762    let mut visited = 0;
763    crate::durable_file::walk_regular_files_nofollow(
764        source_root,
765        |relative, is_directory| {
766            if visited % 256 == 0 {
767                if let Some(control) = control {
768                    control.checkpoint()?;
769                }
770            }
771            visited += 1;
772            if backup_path_excluded(relative) {
773                return Ok(false);
774            }
775            if is_directory {
776                return Ok(true);
777            }
778            if deferred_runs.contains(relative) {
779                return Ok(false);
780            }
781            Ok(!(relative
782                .parent()
783                .and_then(Path::file_name)
784                .is_some_and(|parent| parent == "_runs")
785                && relative
786                    .extension()
787                    .is_some_and(|extension| extension == "sr")))
788        },
789        |relative| {
790            destination_root.create_directory_all(relative)?;
791            Ok(())
792        },
793        |relative, source| {
794            destination_root.copy_new_from(relative, source)?;
795            copied.push(relative.to_path_buf());
796            Ok(())
797        },
798    )
799}
800
801fn backup_path_excluded(relative: &Path) -> bool {
802    relative == Path::new("_meta/.lock")
803        || relative == Path::new("_meta/replica")
804        || relative == Path::new("_meta/repl_epoch")
805        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
806        || relative.components().any(|component| {
807            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
808        })
809}
810
811#[derive(Debug, Clone)]
812pub enum ExternalTriggerWrite {
813    Insert {
814        table: String,
815        cells: Vec<(u16, Value)>,
816    },
817    UpdateByPk {
818        table: String,
819        pk: Value,
820        cells: Vec<(u16, Value)>,
821    },
822    DeleteByPk {
823        table: String,
824        pk: Value,
825    },
826}
827
828impl ExternalTriggerWrite {
829    fn table(&self) -> &str {
830        match self {
831            Self::Insert { table, .. }
832            | Self::UpdateByPk { table, .. }
833            | Self::DeleteByPk { table, .. } => table,
834        }
835    }
836}
837
838#[derive(Debug, Clone, PartialEq)]
839pub enum ExternalTriggerBaseWrite {
840    Put {
841        table: String,
842        cells: Vec<(u16, Value)>,
843    },
844    Delete {
845        table: String,
846        row_id: RowId,
847    },
848}
849
850#[derive(Debug, Clone, PartialEq)]
851pub struct ExternalTriggerWriteResult {
852    pub state: Vec<u8>,
853    pub base_writes: Vec<ExternalTriggerBaseWrite>,
854}
855
856impl ExternalTriggerWriteResult {
857    pub fn new(state: Vec<u8>) -> Self {
858        Self {
859            state,
860            base_writes: Vec::new(),
861        }
862    }
863}
864
865pub trait ExternalTriggerBridge: Send + Sync {
866    fn apply_trigger_external_write(
867        &self,
868        entry: &ExternalTableEntry,
869        base_state: Vec<u8>,
870        op: ExternalTriggerWrite,
871    ) -> Result<ExternalTriggerWriteResult>;
872}
873
874/// A pending uniform-epoch run written during a large transaction (spec §8.5).
875struct SpilledRun {
876    table_id: u64,
877    run_id: u128,
878    pending_path: PathBuf,
879    final_path: PathBuf,
880    rows: Vec<crate::memtable::Row>,
881    row_count: u64,
882    min_rid: u64,
883    max_rid: u64,
884    content_hash: [u8; 32],
885}
886
887const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
888const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
889
890fn encode_spilled_row_chunks(
891    rows: &[crate::memtable::Row],
892    total_bytes: &mut usize,
893    total_limit: usize,
894    control: Option<&crate::ExecutionControl>,
895) -> Result<Vec<Vec<u8>>> {
896    let mut output = Vec::new();
897    let mut start = 0;
898    while start < rows.len() {
899        // Bincode's sequence length prefix is a u64 with the workspace's
900        // fixed-int options. `serialized_size` computes exact row sizes
901        // without first allocating one transaction-sized buffer.
902        let mut estimated_bytes = std::mem::size_of::<u64>();
903        let mut end = start;
904        while end < rows.len() {
905            if end % 256 == 0 {
906                if let Some(control) = control {
907                    control.checkpoint()?;
908                }
909            }
910            let row_bytes =
911                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
912                    MongrelError::ResourceLimitExceeded {
913                        resource: "spilled WAL row bytes",
914                        requested: usize::MAX,
915                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
916                    }
917                })?;
918            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
919                MongrelError::ResourceLimitExceeded {
920                    resource: "spilled WAL row bytes",
921                    requested: usize::MAX,
922                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
923                },
924            )?;
925            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
926                break;
927            }
928            estimated_bytes = next_bytes;
929            end += 1;
930        }
931        if end == start {
932            return Err(MongrelError::ResourceLimitExceeded {
933                resource: "spilled WAL row bytes",
934                requested: estimated_bytes.saturating_add(1),
935                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
936            });
937        }
938        let payload = bincode::serialize(&rows[start..end])?;
939        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
940            return Err(MongrelError::ResourceLimitExceeded {
941                resource: "spilled WAL row bytes",
942                requested: payload.len(),
943                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
944            });
945        }
946        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
947        if requested > total_limit {
948            return Err(MongrelError::ResourceLimitExceeded {
949                resource: "spilled WAL transaction bytes",
950                requested,
951                limit: total_limit,
952            });
953        }
954        *total_bytes = requested;
955        output.push(payload);
956        start = end;
957    }
958    Ok(output)
959}
960
961#[cfg(test)]
962mod spilled_wal_encoding_tests {
963    use super::*;
964
965    #[test]
966    fn logical_spill_payload_has_a_total_bound() {
967        let rows = (0..4)
968            .map(|row_id| crate::memtable::Row {
969                row_id: crate::rowid::RowId(row_id),
970                committed_epoch: Epoch::ZERO,
971                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
972                deleted: false,
973                commit_ts: None,
974            })
975            .collect::<Vec<_>>();
976        let mut total = 0;
977        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
978        assert!(matches!(
979            error,
980            MongrelError::ResourceLimitExceeded {
981                resource: "spilled WAL transaction bytes",
982                ..
983            }
984        ));
985    }
986}
987
988/// Move spill files to their final names before the WAL commit. Dropping this
989/// guard restores pending names while commit is still known not to have begun.
990/// It is disarmed immediately before the first WAL append, where the outcome
991/// can become ambiguous and recovery may need the final names.
992struct PreparedRunLinks {
993    links: Vec<(PathBuf, PathBuf)>,
994    armed: bool,
995}
996
997impl PreparedRunLinks {
998    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
999        let mut guard = Self {
1000            links: Vec::with_capacity(spilled.len()),
1001            armed: true,
1002        };
1003        for run in spilled {
1004            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
1005            guard
1006                .links
1007                .push((run.pending_path.clone(), run.final_path.clone()));
1008        }
1009        Ok(guard)
1010    }
1011
1012    fn disarm(&mut self) {
1013        self.armed = false;
1014        for (pending, _) in &self.links {
1015            if let Some(parent) = pending.parent() {
1016                let _ = std::fs::remove_dir_all(parent);
1017            }
1018        }
1019    }
1020}
1021
1022impl Drop for PreparedRunLinks {
1023    fn drop(&mut self) {
1024        if !self.armed {
1025            return;
1026        }
1027        for (pending, final_path) in self.links.iter().rev() {
1028            let _ = std::fs::rename(final_path, pending);
1029        }
1030    }
1031}
1032
1033struct TableApplyBatch {
1034    table_id: u64,
1035    handle: TableHandle,
1036    ops: Vec<crate::txn::StagedOp>,
1037}
1038
1039#[derive(Debug, Clone)]
1040struct TriggerRowImage {
1041    columns: HashMap<u16, Value>,
1042}
1043
1044impl TriggerRowImage {
1045    fn from_row(row: crate::memtable::Row) -> Self {
1046        Self {
1047            columns: row.columns,
1048        }
1049    }
1050
1051    fn from_cells(cells: &[(u16, Value)]) -> Self {
1052        Self {
1053            columns: cells.iter().cloned().collect(),
1054        }
1055    }
1056}
1057
1058#[derive(Debug, Clone)]
1059struct WriteEvent {
1060    table: String,
1061    kind: TriggerEvent,
1062    old: Option<TriggerRowImage>,
1063    new: Option<TriggerRowImage>,
1064    changed_columns: Vec<u16>,
1065    op_indices: Vec<usize>,
1066    put_idx: Option<usize>,
1067    trigger_stack: Vec<String>,
1068}
1069
1070#[derive(Default)]
1071struct TriggerExpansion {
1072    before: Vec<(u64, crate::txn::Staged)>,
1073    before_stacks: Vec<Vec<String>>,
1074    before_external: Vec<ExternalTriggerWrite>,
1075    after: Vec<(u64, crate::txn::Staged)>,
1076    after_stacks: Vec<Vec<String>>,
1077    after_external: Vec<ExternalTriggerWrite>,
1078    ignored_indices: std::collections::BTreeSet<usize>,
1079}
1080
1081#[derive(Clone, PartialEq)]
1082struct TriggerCatalogBinding {
1083    triggers: Vec<TriggerEntry>,
1084    tables: Vec<(String, u64, u64)>,
1085    external_tables: Vec<(String, u64, u64)>,
1086}
1087
1088fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
1089    let mut triggers = catalog
1090        .triggers
1091        .iter()
1092        .filter(|entry| entry.trigger.enabled)
1093        .cloned()
1094        .collect::<Vec<_>>();
1095    if triggers.is_empty() {
1096        return None;
1097    }
1098    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
1099    let mut tables = catalog
1100        .tables
1101        .iter()
1102        .filter(|entry| matches!(entry.state, TableState::Live))
1103        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
1104        .collect::<Vec<_>>();
1105    tables.sort_unstable();
1106    let mut external_tables = catalog
1107        .external_tables
1108        .iter()
1109        .map(|entry| {
1110            (
1111                entry.name.clone(),
1112                entry.created_epoch,
1113                entry.declared_schema.schema_id,
1114            )
1115        })
1116        .collect::<Vec<_>>();
1117    external_tables.sort_unstable();
1118    Some(TriggerCatalogBinding {
1119        triggers,
1120        tables,
1121        external_tables,
1122    })
1123}
1124
1125struct TriggerProgramOutput<'a> {
1126    added: &'a mut Vec<(u64, crate::txn::Staged)>,
1127    added_stacks: &'a mut Vec<Vec<String>>,
1128    added_external: &'a mut Vec<ExternalTriggerWrite>,
1129    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
1130}
1131
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1133enum TriggerProgramOutcome {
1134    Continue,
1135    Ignore,
1136}
1137
1138/// An integrity issue found by [`Database::check`] (spec §16).
1139#[derive(Debug, Clone)]
1140pub struct CheckIssue {
1141    pub table_id: u64,
1142    pub table_name: String,
1143    pub severity: String,
1144    pub description: String,
1145}
1146
1147/// One optimistic authorization snapshot for a complete scored read.
1148#[derive(Debug, Clone)]
1149pub struct AuthorizedReadSnapshot {
1150    pub table: String,
1151    pub table_snapshot: Snapshot,
1152    pub data_generation: u64,
1153    pub security_version: u64,
1154    pub allowed_row_ids: Option<HashSet<RowId>>,
1155}
1156
1157/// Exact table/security generation used by one successful authorized read.
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1159pub struct AuthorizedReadStamp {
1160    pub table_id: u64,
1161    pub schema_id: u64,
1162    pub data_generation: u64,
1163    pub security_version: u64,
1164    pub snapshot: Snapshot,
1165}
1166
1167type RlsCacheKey = (String, u64, u64, String);
1168
1169/// Runtime statistics for the byte-bounded RLS candidate cache.
1170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1171pub struct RlsCacheStats {
1172    pub entries: usize,
1173    pub bytes: usize,
1174    pub hits: u64,
1175    pub misses: u64,
1176    pub evictions: u64,
1177    pub build_nanos: u64,
1178    pub rows_evaluated: u64,
1179}
1180
1181const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
1182const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
1183const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
1184const CDC_MAX_EVENTS: usize = 100_000;
1185const CDC_MAX_ROWS: usize = 1_000_000;
1186const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
1187const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
1188
1189fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
1190    let requested = total.saturating_add(amount);
1191    if requested > CDC_MAX_RETAINED_BYTES {
1192        return Err(MongrelError::ResourceLimitExceeded {
1193            resource,
1194            requested,
1195            limit: CDC_MAX_RETAINED_BYTES,
1196        });
1197    }
1198    *total = requested;
1199    Ok(())
1200}
1201
1202fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
1203    usize::try_from(row.estimated_bytes())
1204        .unwrap_or(usize::MAX)
1205        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
1206}
1207
1208fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
1209    let value_slot = std::mem::size_of::<serde_json::Value>();
1210    row.columns.values().fold(512_usize, |bytes, value| {
1211        let values = match value {
1212            Value::Bytes(values) => values.len(),
1213            Value::Json(values) => values.len(),
1214            Value::Embedding(values) => values.len(),
1215            Value::GeneratedEmbedding(value) => value.vector.len(),
1216            _ => 1,
1217        };
1218        bytes.saturating_add(values.saturating_mul(value_slot))
1219    })
1220}
1221
1222fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
1223    rows.iter().fold(0_usize, |bytes, row| {
1224        bytes.saturating_add(cdc_row_json_bytes(row))
1225    })
1226}
1227
1228struct RlsCacheEntry {
1229    value: Arc<HashSet<RowId>>,
1230    bytes: usize,
1231    generation: u64,
1232}
1233
1234#[derive(Default)]
1235struct RlsCache {
1236    entries: HashMap<RlsCacheKey, RlsCacheEntry>,
1237    lru: BTreeSet<(u64, RlsCacheKey)>,
1238    next_generation: u64,
1239    bytes: usize,
1240    hits: u64,
1241    misses: u64,
1242    evictions: u64,
1243    build_nanos: u64,
1244    rows_evaluated: u64,
1245}
1246
1247impl RlsCache {
1248    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
1249        let value = self.entries.get(key).map(|entry| Arc::clone(&entry.value));
1250        if value.is_some() {
1251            self.hits = self.hits.saturating_add(1);
1252            self.touch(key);
1253        } else {
1254            self.misses = self.misses.saturating_add(1);
1255        }
1256        value
1257    }
1258
1259    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
1260        let bytes = key
1261            .0
1262            .len()
1263            .saturating_add(key.3.len())
1264            .saturating_add(
1265                value
1266                    .capacity()
1267                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
1268            )
1269            .saturating_add(std::mem::size_of::<RlsCacheKey>());
1270        if bytes > RLS_CACHE_MAX_BYTES {
1271            return;
1272        }
1273        if let Some(old) = self.entries.remove(&key) {
1274            self.bytes = self.bytes.saturating_sub(old.bytes);
1275            self.lru.remove(&(old.generation, key.clone()));
1276        }
1277        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
1278            let Some((_, oldest)) = self.lru.pop_first() else {
1279                break;
1280            };
1281            if let Some(old) = self.entries.remove(&oldest) {
1282                self.bytes = self.bytes.saturating_sub(old.bytes);
1283                self.evictions = self.evictions.saturating_add(1);
1284            }
1285        }
1286        let generation = self.allocate_generation();
1287        self.bytes = self.bytes.saturating_add(bytes);
1288        self.lru.insert((generation, key.clone()));
1289        self.entries.insert(
1290            key,
1291            RlsCacheEntry {
1292                value,
1293                bytes,
1294                generation,
1295            },
1296        );
1297    }
1298
1299    fn touch(&mut self, key: &RlsCacheKey) {
1300        let Some(previous) = self.entries.get(key).map(|entry| entry.generation) else {
1301            return;
1302        };
1303        self.lru.remove(&(previous, key.clone()));
1304        let generation = self.allocate_generation();
1305        if let Some(entry) = self.entries.get_mut(key) {
1306            entry.generation = generation;
1307        }
1308        self.lru.insert((generation, key.clone()));
1309    }
1310
1311    fn allocate_generation(&mut self) -> u64 {
1312        if self.next_generation == u64::MAX {
1313            self.rebase_generations();
1314        }
1315        let generation = self.next_generation;
1316        self.next_generation += 1;
1317        generation
1318    }
1319
1320    fn rebase_generations(&mut self) {
1321        let ordered = std::mem::take(&mut self.lru);
1322        for (generation, (_, key)) in ordered.into_iter().enumerate() {
1323            let generation = generation as u64;
1324            if let Some(entry) = self.entries.get_mut(&key) {
1325                entry.generation = generation;
1326                self.lru.insert((generation, key));
1327            }
1328        }
1329        self.next_generation = self.lru.len() as u64;
1330    }
1331
1332    fn stats(&self) -> RlsCacheStats {
1333        RlsCacheStats {
1334            entries: self.entries.len(),
1335            bytes: self.bytes,
1336            hits: self.hits,
1337            misses: self.misses,
1338            evictions: self.evictions,
1339            build_nanos: self.build_nanos,
1340            rows_evaluated: self.rows_evaluated,
1341        }
1342    }
1343}
1344
1345#[cfg(test)]
1346mod rls_cache_tests {
1347    use super::*;
1348
1349    fn key(principal: &str) -> RlsCacheKey {
1350        ("table".into(), 1, 1, principal.into())
1351    }
1352
1353    fn rows(row_id: u64) -> Arc<HashSet<RowId>> {
1354        Arc::new(std::iter::once(RowId(row_id)).collect())
1355    }
1356
1357    #[test]
1358    fn hits_update_recency_without_growing_the_order_index() {
1359        let mut cache = RlsCache::default();
1360        let first = key("first");
1361        let second = key("second");
1362        cache.insert(first.clone(), rows(1));
1363        cache.insert(second.clone(), rows(2));
1364        assert_eq!(cache.lru.len(), cache.entries.len());
1365
1366        for _ in 0..1_000 {
1367            assert!(cache.get(&first).is_some());
1368        }
1369
1370        assert_eq!(cache.lru.len(), cache.entries.len());
1371        assert_eq!(
1372            cache.lru.first().map(|(_, candidate)| candidate),
1373            Some(&second)
1374        );
1375
1376        cache.insert(first.clone(), rows(3));
1377        assert_eq!(cache.lru.len(), cache.entries.len());
1378        let replacement = cache.get(&first).unwrap();
1379        assert_eq!(replacement.len(), 1);
1380        assert!(replacement.contains(&RowId(3)));
1381    }
1382}
1383
1384/// Mounted table with immutable, structurally shared scored-read generations.
1385#[derive(Clone)]
1386pub struct TableHandle {
1387    inner: TableHandleInner,
1388    generation_metrics: Arc<TableGenerationMetrics>,
1389}
1390
1391#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1392pub struct TableGenerationStats {
1393    pub active_read_generations: usize,
1394    pub max_live_read_generations: usize,
1395    pub cow_clone_count: u64,
1396    pub cow_clone_nanos: u64,
1397    pub estimated_cow_clone_bytes: u64,
1398    pub writer_wait_nanos: u64,
1399}
1400
1401#[derive(Default)]
1402#[doc(hidden)]
1403pub struct TableGenerationMetrics {
1404    active_read_generations: AtomicUsize,
1405    max_live_read_generations: AtomicUsize,
1406    cow_clone_count: AtomicU64,
1407    cow_clone_nanos: AtomicU64,
1408    estimated_cow_clone_bytes: AtomicU64,
1409    writer_wait_nanos: AtomicU64,
1410}
1411
1412impl TableGenerationMetrics {
1413    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
1414        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
1415        self.max_live_read_generations
1416            .fetch_max(active, Ordering::Relaxed);
1417        Arc::new(TableReadGeneration {
1418            table,
1419            metrics: Arc::clone(self),
1420        })
1421    }
1422
1423    fn stats(&self) -> TableGenerationStats {
1424        TableGenerationStats {
1425            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
1426            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
1427            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
1428            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
1429            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
1430            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
1431        }
1432    }
1433}
1434
1435/// Immutable, structurally shared snapshot used by scored readers.
1436pub struct TableReadGeneration {
1437    table: Table,
1438    metrics: Arc<TableGenerationMetrics>,
1439}
1440
1441impl std::ops::Deref for TableReadGeneration {
1442    type Target = Table;
1443
1444    fn deref(&self) -> &Self::Target {
1445        &self.table
1446    }
1447}
1448
1449impl Drop for TableReadGeneration {
1450    fn drop(&mut self) {
1451        self.metrics
1452            .active_read_generations
1453            .fetch_sub(1, Ordering::Relaxed);
1454    }
1455}
1456
1457#[derive(Clone)]
1458enum TableHandleInner {
1459    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
1460    Direct(Arc<Mutex<Table>>),
1461}
1462
1463pub enum TableGuard<'a> {
1464    CopyOnWrite {
1465        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
1466        metrics: Arc<TableGenerationMetrics>,
1467    },
1468    Direct {
1469        table: parking_lot::MutexGuard<'a, Table>,
1470    },
1471}
1472
1473/// Read-only guard for one mounted table.
1474///
1475/// Copy-on-write handles use the shared side of the table `RwLock`, so
1476/// independent readers do not serialize on the writer lock. Legacy direct
1477/// handles retain their existing mutex semantics.
1478pub enum TableReadGuard<'a> {
1479    CopyOnWrite {
1480        table: parking_lot::RwLockReadGuard<'a, Arc<Table>>,
1481    },
1482    Direct {
1483        table: parking_lot::MutexGuard<'a, Table>,
1484    },
1485}
1486
1487impl TableHandle {
1488    fn new(table: Table) -> Self {
1489        Self {
1490            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
1491            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1492        }
1493    }
1494
1495    pub fn from_table(table: Table) -> Self {
1496        Self::new(table)
1497    }
1498
1499    /// Acquire a read-only table guard. Copy-on-write handles admit concurrent
1500    /// readers; callers that mutate the table must continue to use [`Self::lock`].
1501    pub fn read(&self) -> TableReadGuard<'_> {
1502        match &self.inner {
1503            TableHandleInner::CopyOnWrite(table) => TableReadGuard::CopyOnWrite {
1504                table: table.read(),
1505            },
1506            TableHandleInner::Direct(table) => TableReadGuard::Direct {
1507                table: table.lock(),
1508            },
1509        }
1510    }
1511
1512    pub fn lock(&self) -> TableGuard<'_> {
1513        let started = std::time::Instant::now();
1514        let guard = match &self.inner {
1515            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
1516                table: table.write(),
1517                metrics: Arc::clone(&self.generation_metrics),
1518            },
1519            TableHandleInner::Direct(table) => TableGuard::Direct {
1520                table: table.lock(),
1521            },
1522        };
1523        self.generation_metrics.writer_wait_nanos.fetch_add(
1524            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1525            Ordering::Relaxed,
1526        );
1527        guard
1528    }
1529
1530    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
1531        let started = std::time::Instant::now();
1532        let guard = match &self.inner {
1533            TableHandleInner::CopyOnWrite(table) => {
1534                table
1535                    .try_write_for(timeout)
1536                    .map(|table| TableGuard::CopyOnWrite {
1537                        table,
1538                        metrics: Arc::clone(&self.generation_metrics),
1539                    })
1540            }
1541            TableHandleInner::Direct(table) => table
1542                .try_lock_for(timeout)
1543                .map(|table| TableGuard::Direct { table }),
1544        };
1545        self.generation_metrics.writer_wait_nanos.fetch_add(
1546            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1547            Ordering::Relaxed,
1548        );
1549        guard
1550    }
1551
1552    pub fn ptr_eq(&self, other: &Self) -> bool {
1553        match (&self.inner, &other.inner) {
1554            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1555                Arc::ptr_eq(left, right)
1556            }
1557            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1558                Arc::ptr_eq(left, right)
1559            }
1560            _ => false,
1561        }
1562    }
1563
1564    pub fn read_generation_with_context(
1565        &self,
1566        context: Option<&crate::query::AiExecutionContext>,
1567    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1568        let mut table = if let Some(context) = context {
1569            loop {
1570                context.checkpoint()?;
1571                let wait = context
1572                    .remaining_duration()
1573                    .unwrap_or(std::time::Duration::from_millis(5))
1574                    .min(std::time::Duration::from_millis(5));
1575                if let Some(table) = self.try_lock_for(wait) {
1576                    break table;
1577                }
1578            }
1579        } else {
1580            self.lock()
1581        };
1582        let snapshot = table.snapshot();
1583        let generation = table.clone_read_generation()?;
1584        Ok((self.generation_metrics.activate(generation), snapshot))
1585    }
1586
1587    pub fn generation_stats(&self) -> TableGenerationStats {
1588        self.generation_metrics.stats()
1589    }
1590}
1591
1592impl From<Arc<Mutex<Table>>> for TableHandle {
1593    fn from(table: Arc<Mutex<Table>>) -> Self {
1594        Self {
1595            inner: TableHandleInner::Direct(table),
1596            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1597        }
1598    }
1599}
1600
1601impl std::ops::Deref for TableGuard<'_> {
1602    type Target = Table;
1603
1604    fn deref(&self) -> &Self::Target {
1605        match self {
1606            Self::CopyOnWrite { table, .. } => table.as_ref(),
1607            Self::Direct { table } => table,
1608        }
1609    }
1610}
1611
1612impl std::ops::Deref for TableReadGuard<'_> {
1613    type Target = Table;
1614
1615    fn deref(&self) -> &Self::Target {
1616        match self {
1617            Self::CopyOnWrite { table } => table.as_ref(),
1618            Self::Direct { table } => table,
1619        }
1620    }
1621}
1622
1623impl std::ops::DerefMut for TableGuard<'_> {
1624    fn deref_mut(&mut self) -> &mut Self::Target {
1625        match self {
1626            Self::CopyOnWrite { table, metrics } => {
1627                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1628                    let estimated_bytes = table.estimated_clone_bytes();
1629                    let started = std::time::Instant::now();
1630                    let table = Arc::make_mut(table);
1631                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1632                    metrics.cow_clone_nanos.fetch_add(
1633                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1634                        Ordering::Relaxed,
1635                    );
1636                    metrics
1637                        .estimated_cow_clone_bytes
1638                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1639                    table
1640                } else {
1641                    Arc::make_mut(table)
1642                }
1643            }
1644            Self::Direct { table } => table,
1645        }
1646    }
1647}
1648
1649#[derive(Clone, Debug)]
1650pub struct ReadAuthorization {
1651    pub operation: crate::auth::ColumnOperation,
1652    pub columns: Vec<u16>,
1653    pub permissions: Vec<crate::auth::Permission>,
1654}
1655
1656#[derive(Default, Debug)]
1657struct TableWritePermissionNeeds {
1658    insert: bool,
1659    insert_columns: Vec<u16>,
1660    update: bool,
1661    update_columns: Vec<u16>,
1662    delete: bool,
1663    truncate: bool,
1664}
1665
1666#[cfg(test)]
1667thread_local! {
1668    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1669    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1670    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1671    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1672    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1673    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1674}
1675
1676fn summarize_write_permissions(
1677    staging: &[(u64, crate::txn::Staged)],
1678) -> HashMap<u64, TableWritePermissionNeeds> {
1679    use crate::txn::Staged;
1680
1681    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1682    for (table_id, operation) in staging {
1683        let table = needs.entry(*table_id).or_default();
1684        match operation {
1685            Staged::Put(cells) => {
1686                table.insert = true;
1687                table
1688                    .insert_columns
1689                    .extend(cells.iter().map(|(column, _)| *column));
1690            }
1691            Staged::Update {
1692                changed_columns, ..
1693            } => {
1694                table.update = true;
1695                table.update_columns.extend(changed_columns);
1696            }
1697            Staged::Delete(_) => table.delete = true,
1698            Staged::Truncate => table.truncate = true,
1699        }
1700    }
1701    for table in needs.values_mut() {
1702        table.insert_columns.sort_unstable();
1703        table.insert_columns.dedup();
1704        table.update_columns.sort_unstable();
1705        table.update_columns.dedup();
1706    }
1707    needs
1708}
1709
1710struct SecurityCoordinator {
1711    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1712    gate: RwLock<()>,
1713    version: AtomicU64,
1714}
1715
1716fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1717    static COORDINATORS: std::sync::OnceLock<
1718        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1719    > = std::sync::OnceLock::new();
1720
1721    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1722    let mut coordinators = COORDINATORS
1723        .get_or_init(|| Mutex::new(HashMap::new()))
1724        .lock();
1725    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1726    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1727        return coordinator;
1728    }
1729    let coordinator = Arc::new(SecurityCoordinator {
1730        gate: RwLock::new(()),
1731        version: AtomicU64::new(version),
1732    });
1733    coordinators.insert(root, Arc::downgrade(&coordinator));
1734    coordinator
1735}
1736
1737pub fn lock_table_with_context<'a>(
1738    handle: &'a TableHandle,
1739    context: Option<&crate::query::AiExecutionContext>,
1740) -> Result<TableGuard<'a>> {
1741    let Some(context) = context else {
1742        return Ok(handle.lock());
1743    };
1744    loop {
1745        context.checkpoint()?;
1746        let wait = context
1747            .remaining_duration()
1748            .unwrap_or(std::time::Duration::from_millis(5))
1749            .min(std::time::Duration::from_millis(5));
1750        if let Some(guard) = handle.try_lock_for(wait) {
1751            return Ok(guard);
1752        }
1753    }
1754}
1755
1756/// Knobs for [`Database::open_with_options`].
1757///
1758/// All fields default to the same values the convenience
1759/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1760/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1761#[derive(Clone, Debug, Default)]
1762pub struct OpenOptions {
1763    /// Maximum time, in milliseconds, to wait for the cross-process database
1764    /// lock (`_meta/.lock`) before failing with `MongrelError::DatabaseLocked`.
1765    ///
1766    /// `0` (the default) preserves the historical fail-fast semantics: a
1767    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1768    /// `busy_timeout` semantics kick in once this is non-zero — the open
1769    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1770    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1771    /// point the open returns the same typed lock error as the fail-fast path.
1772    ///
1773    /// Only the cross-process lock is affected. Mounted tables, page-cache
1774    /// misses, and WAL appends already serialize through in-process locks
1775    /// that handle their own contention. A second independent open in the
1776    /// same process always returns `DatabaseLocked` immediately; share the
1777    /// existing `Arc<Database>` instead.
1778    pub lock_timeout_ms: u32,
1779    /// Total bytes the storage core's [`crate::memory::MemoryGovernor`] may
1780    /// hand out across every memory class (S1E-003). `None` (the default)
1781    /// uses [`DEFAULT_MEMORY_BUDGET_BYTES`]. The governor is a reservation
1782    /// accounting layer, not an allocation: this is a cap, not a preallocation.
1783    pub memory_budget_bytes: Option<u64>,
1784    /// Total bytes of live spill files the core's
1785    /// [`crate::spill::SpillManager`] allows across every query (S1E-004).
1786    /// `None` (the default) uses [`DEFAULT_TEMP_DISK_BUDGET_BYTES`]. Again a
1787    /// cap, not a preallocation.
1788    pub temp_disk_budget_bytes: Option<u64>,
1789    /// Open the database through the special offline-validation API (spec
1790    /// section 5.3): any [`crate::storage_mode::StorageMode`] — including
1791    /// `ClusterReplica`, which every normal open path rejects — is opened
1792    /// **read-only** so a backup validator can inspect it. The opened core
1793    /// rejects every write with [`MongrelError::ReadOnlyReplica`]. This is the
1794    /// only way to open a cluster replica outside the cluster node runtime.
1795    pub offline_validation: bool,
1796}
1797
1798impl OpenOptions {
1799    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1800    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1801    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1802        self.lock_timeout_ms = ms;
1803        self
1804    }
1805
1806    /// Set [`OpenOptions::memory_budget_bytes`].
1807    pub fn with_memory_budget_bytes(mut self, bytes: u64) -> Self {
1808        self.memory_budget_bytes = Some(bytes);
1809        self
1810    }
1811
1812    /// Set [`OpenOptions::temp_disk_budget_bytes`].
1813    pub fn with_temp_disk_budget_bytes(mut self, bytes: u64) -> Self {
1814        self.temp_disk_budget_bytes = Some(bytes);
1815        self
1816    }
1817
1818    /// Set [`OpenOptions::offline_validation`]. `true` opens any storage mode
1819    /// read-only (the offline backup-validator API of spec section 5.3).
1820    pub fn with_offline_validation(mut self, offline_validation: bool) -> Self {
1821        self.offline_validation = offline_validation;
1822        self
1823    }
1824}
1825
1826/// How an open/create path treats the durable storage-mode marker (spec
1827/// section 5.3, Stage 2E). Threaded from the public constructors through the
1828/// open chain into [`Database::finish_open`].
1829#[derive(Debug, Clone)]
1830pub(crate) enum OpenModeGate {
1831    /// Normal embedded/server open: `ClusterReplica` markers are rejected with
1832    /// [`crate::storage_mode::StorageModeError::ClusterReplicaRequiresClusterRuntime`].
1833    Normal,
1834    /// Process-local shared core. Authentication binds to each issued handle,
1835    /// so core recovery and table mounting proceed without a caller principal.
1836    SharedCore,
1837    /// Offline backup validator: any mode opens, forced read-only.
1838    OfflineValidation,
1839    /// Cluster node runtime: the marker must exist and equal this
1840    /// `ClusterReplica` identity; the core opens read-only for user paths (all
1841    /// writes arrive through the replicated apply path).
1842    ClusterRuntime {
1843        /// Expected owning cluster.
1844        cluster_id: mongreldb_types::ids::ClusterId,
1845        /// Expected owning node.
1846        node_id: mongreldb_types::ids::NodeId,
1847        /// Expected replicated database.
1848        database_id: mongreldb_types::ids::DatabaseId,
1849    },
1850    /// Fresh create: no marker may exist yet; this mode is written.
1851    Create(crate::storage_mode::StorageMode),
1852}
1853
1854/// Default node-level memory budget (S1E-003): 1 GiB. Comfortably covers the
1855/// two 64 MiB caches with headroom for query execution, result buffering, and
1856/// maintenance reservations.
1857pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
1858
1859/// Default node-level temporary-disk (spill) budget (S1E-004): 4 GiB of live
1860/// spill files across every query on the core.
1861pub const DEFAULT_TEMP_DISK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
1862
1863/// Resolved node-level resource budgets for one storage core
1864/// (S1E-003/S1E-004), threaded from [`OpenOptions`] through the open chain
1865/// into [`Database::finish_open`].
1866#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1867pub(crate) struct CoreResourceConfig {
1868    pub memory_budget_bytes: u64,
1869    pub temp_disk_budget_bytes: u64,
1870}
1871
1872impl Default for CoreResourceConfig {
1873    fn default() -> Self {
1874        Self {
1875            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
1876            temp_disk_budget_bytes: DEFAULT_TEMP_DISK_BUDGET_BYTES,
1877        }
1878    }
1879}
1880
1881impl CoreResourceConfig {
1882    fn from_options(options: &OpenOptions) -> Result<Self> {
1883        let config = Self {
1884            memory_budget_bytes: options
1885                .memory_budget_bytes
1886                .unwrap_or(DEFAULT_MEMORY_BUDGET_BYTES),
1887            temp_disk_budget_bytes: options
1888                .temp_disk_budget_bytes
1889                .unwrap_or(DEFAULT_TEMP_DISK_BUDGET_BYTES),
1890        };
1891        if config.memory_budget_bytes == 0 {
1892            return Err(MongrelError::InvalidArgument(
1893                "memory_budget_bytes must be nonzero".into(),
1894            ));
1895        }
1896        if config.temp_disk_budget_bytes == 0 {
1897            return Err(MongrelError::InvalidArgument(
1898                "temp_disk_budget_bytes must be nonzero".into(),
1899            ));
1900        }
1901        Ok(config)
1902    }
1903}
1904
1905/// Per-table version-retention pin diagnostics (S1C-004): one mounted table's
1906/// active pin sources as reported by [`crate::engine::Table::version_pins_report`].
1907#[derive(Debug, Clone)]
1908pub struct TablePinsReport {
1909    /// The mounted table's id.
1910    pub table_id: u64,
1911    /// The mounted table's catalog name.
1912    pub table: String,
1913    /// Every active version-retention pin source on the table.
1914    pub pins: crate::retention::PinsReport,
1915}
1916
1917/// The shared storage core (spec §10.1, S1A-001): one catalog, one epoch
1918/// clock, shared caches, a shared WAL, and a live map of name → `Arc<Table>`.
1919///
1920/// `DatabaseCore` owns every storage resource — the durable root, the
1921/// exclusive lease, the catalog, the commit log, the epoch authority, the
1922/// transaction state, the mounted tables, and the page caches — plus the
1923/// lifecycle state machine (S1A-004). It is shared through an `Arc`: the
1924/// exclusive [`Database`] owner holds one reference, and every
1925/// [`crate::handle::DatabaseHandle`] issued by
1926/// [`crate::manager::DatabaseManager`] holds another. The core deliberately
1927/// does **not** store one mutable "current principal" (spec §4.6): per-caller
1928/// identity lives on the handle types, not inside shared storage state.
1929pub struct DatabaseCore {
1930    root: PathBuf,
1931    durable_root: Arc<crate::durable_file::DurableRoot>,
1932    /// Lifecycle state machine (spec §10.1, S1A-004). Shared through an `Arc`
1933    /// so [`crate::core::OperationGuard`]s are `'static` and thread-safe.
1934    lifecycle: Arc<crate::core::LifecycleController>,
1935    /// Set by [`crate::manager::DatabaseManager`] when this core backs shared
1936    /// handles: lets `shutdown()` transition the registry entry through
1937    /// `Closing` to removal. `None` for exclusively-owned cores.
1938    registry: std::sync::OnceLock<crate::manager::CoreRegistration>,
1939    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1940    read_only: bool,
1941    catalog: RwLock<Catalog>,
1942    security_coordinator: Arc<SecurityCoordinator>,
1943    security_catalog_disk_reads: AtomicU64,
1944    rls_cache: Mutex<RlsCache>,
1945    epoch: Arc<EpochAuthority>,
1946    snapshots: Arc<SnapshotRegistry>,
1947    /// S1E-003: the node-level memory governor for this core. Exactly one per
1948    /// core; both caches below hold reservations under it and are registered
1949    /// as reclaimable subsystems it can evict under pressure (escalation
1950    /// step 2).
1951    memory_governor: crate::memory::MemoryGovernor,
1952    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1953    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1954    /// S1E-004: the node-level spill manager, rooted at `<db-root>/temp/spill`
1955    /// and sealed with the database meta DEK when encryption is enabled. Its
1956    /// startup sweep ran during open; per-query sessions draw from it.
1957    spill_manager: crate::spill::SpillManager,
1958    /// S1E-002: workload resource groups for admission (concurrency, memory,
1959    /// temp disk, work units). Seeded with class defaults at open; operators
1960    /// reconfigure through [`Database::resource_groups`].
1961    resource_groups: crate::resource::ResourceGroupRegistry,
1962    /// Optional pluggable embedding providers (local models / registered
1963    /// backends). Empty by default — dense ANN uses application-supplied
1964    /// vectors; sparse retrieval needs no provider.
1965    embedding_providers: crate::embedding::EmbeddingProviderRegistry,
1966    /// Authenticated service-principal definitions for shared-handle open
1967    /// (P0.1). Authority is stored here and re-resolved live per operation;
1968    /// callers cannot supply their own permission vectors on attach.
1969    pub(crate) service_principals: crate::service_principal::ServicePrincipalStore,
1970    /// S1F-002: the durable job registry (sibling `JOBS` file). Exactly one
1971    /// per core; jobs recovered mid-`Running` by a crash come back `Paused`.
1972    job_registry: Arc<crate::jobs::JobRegistry>,
1973    commit_lock: Arc<Mutex<()>>,
1974    kms_rotation_lock: Mutex<()>,
1975    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1976    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1977    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1978    /// writes also land in this one WAL (B1 — one WAL per database).
1979    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1980    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1981    /// in P2.7; here it just needs to be unique within an open. Shared with
1982    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1983    next_txn_id: Arc<Mutex<u64>>,
1984    tables: RwLock<HashMap<u64, TableHandle>>,
1985    kek: Option<Arc<crate::encryption::Kek>>,
1986    /// Serializes DDL (create/drop table); data commits serialize through
1987    /// `commit_lock` shared via `SharedCtx`.
1988    ddl_lock: Mutex<()>,
1989    meta_dek: Option<[u8; META_DEK_LEN]>,
1990    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1991    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1992    spill_threshold: std::sync::atomic::AtomicU64,
1993    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1994    /// detection (spec §9.2).
1995    conflicts: crate::txn::ConflictIndex,
1996    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1997    /// pruning (spec §9.2, review fix #12).
1998    active_txns: crate::txn::ActiveTxns,
1999    /// S1B-003: the core's key/predicate lock manager. One per core; commits
2000    /// acquire unique-constraint key claims, FK parent-protection holds,
2001    /// sequence-allocation barriers, and the DML Shared hold on the schema
2002    /// barrier (DDL takes it Exclusive), all released when the transaction
2003    /// ends.
2004    lock_manager: Arc<crate::locks::LockManager>,
2005    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
2006    /// Shared with mounted tables so a single-table commit also honors poison.
2007    poisoned: Arc<std::sync::atomic::AtomicBool>,
2008    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
2009    /// but defers the fsync to one leader here, so concurrent commits share a
2010    /// single fsync (spec §9.3). Shared with mounted tables.
2011    group: Arc<crate::txn::GroupCommit>,
2012    /// FND-004 (spec §9.4): configured commit authority. Standalone cores bind
2013    /// `StandaloneCommitLog`; cluster runtime replaces it with the tablet
2014    /// group's `RaftCommitLog` after group construction.
2015    commit_log: RwLock<Arc<dyn mongreldb_log::CommitLog>>,
2016    /// Standalone WAL adapter used only by local standalone mutation paths.
2017    /// Cluster replicas reject those paths and receive mutations through
2018    /// committed apply.
2019    standalone_commit_log: Arc<crate::commit_log::StandaloneCommitLog>,
2020    /// The node's single HLC timestamp authority (spec §4.1, §8.2; ADR-0003).
2021    /// Transaction read timestamps are captured at `begin`, and commit
2022    /// timestamps are allocated here under the sequencer lock (strictly after
2023    /// every participant read/write timestamp). `Epoch` remains the
2024    /// reader-visibility counter during the dual-model migration.
2025    hlc: Arc<mongreldb_types::hlc::HlcClock>,
2026    /// S1B-005: durable idempotency ledger for keyed remote writes, persisted
2027    /// in the sibling `TXN_IDEMPOTENCY` file (mirroring `jobs.rs`'s `JOBS`).
2028    idempotency: crate::txn::IdempotencyLedger,
2029    /// Per-open epoch → commit-timestamp ledger backing
2030    /// [`Database::commit_ts_for_epoch`]. Commits sealed within this open
2031    /// record the exact receipt `HlcTimestamp`; commits recovered from the
2032    /// durable `Op::CommitTimestamp` WAL ledger at open reconstruct the
2033    /// physical component only (`logical`/`node_tiebreaker` are 0 — the
2034    /// ledger byte format stores micros). Bounded to the newest
2035    /// [`COMMIT_TS_LEDGER_CAP`] epochs.
2036    commit_ts_ledger: Mutex<std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp>>,
2037    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
2038    /// live spill's pending dir (review fix #14, spec §6.4).
2039    active_spills: Arc<crate::retention::ActiveSpills>,
2040    /// A write lock captures a consistent bootstrap image; transaction commits
2041    /// hold a read lock across spill preparation, WAL append, and publish.
2042    replication_barrier: parking_lot::RwLock<()>,
2043    /// Number of rotated WAL segments retained for lagging followers.
2044    replication_wal_retention_segments: AtomicUsize,
2045    /// Live immutable run files used by online backups or scored read
2046    /// generations. GC cannot unlink them until every owning guard drops.
2047    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
2048    /// Test-only barrier invoked after a transaction writes its spill runs but
2049    /// before the sequencer/publish, so tests can race `gc()` against an
2050    /// in-flight spill. `None` in production.
2051    #[doc(hidden)]
2052    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2053    /// Test seam after the security read gate is held and before WAL append.
2054    #[doc(hidden)]
2055    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2056    /// Test seam after transaction preparation and before catalog generation
2057    /// validation under the commit sequencer.
2058    #[doc(hidden)]
2059    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2060    /// Test seam after a backup boundary is captured and before pinned runs are
2061    /// copied. Lets tests compact+GC the source at the worst possible moment.
2062    #[doc(hidden)]
2063    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2064    /// Test seam invoked after each successful FK parent-protection lock
2065    /// acquisition inside constraint validation, so tests can rendezvous two
2066    /// committing transactions into a deterministic wait-for cycle. Behind an
2067    /// `Arc` so firing never holds the slot's mutex — concurrent commits (and
2068    /// a hook that itself parks) must not serialize on it.
2069    #[doc(hidden)]
2070    fk_lock_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
2071    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2072    trigger_recursive: AtomicBool,
2073    trigger_max_depth: AtomicU32,
2074    trigger_max_loop_iterations: AtomicU32,
2075    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
2076    /// is reconstructed from the WAL by [`Database::change_events_since`].
2077    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
2078    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
2079    /// from the WAL, so lagged receivers lose only a wake-up, never data.
2080    change_wake: tokio::sync::broadcast::Sender<()>,
2081    /// Final field so every storage resource drops before the exclusive lease.
2082    /// Behind a `Mutex<Option<_>>` so `shutdown()` can release the file lock
2083    /// (spec §10.1, S1A-004 step 7) while handles still reference the core.
2084    _lock: Mutex<Option<ExclusiveDatabaseLease>>,
2085}
2086
2087/// The exclusive public owner of one [`DatabaseCore`] (spec §10.1, S1A-001).
2088///
2089/// `Database::open`/`create*` build exactly one core for a root and reject any
2090/// other core — exclusive or shared — for the same root (spec §2.6). All
2091/// storage state lives on the core; this type adds the owner-bound identity
2092/// context (the cached principal and the shared auth state used by the
2093/// embedded enforcement path). Method calls and field reads transparently
2094/// reach the core through `Deref`, so existing `Database` call sites are
2095/// unchanged.
2096pub struct Database {
2097    core: Arc<DatabaseCore>,
2098    /// The authenticated principal for this owner. `None` on databases
2099    /// opened without credentials (the default — `require_auth = false`),
2100    /// `Some` on credentialed opens. Consulted by every enforcement point
2101    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
2102    /// because the access pattern is read-heavy: every `require()` call
2103    /// reads the principal, while writes happen only at open, `enable_auth`,
2104    /// and `refresh_principal`. This matches the engine's existing use of
2105    /// `RwLock` for `catalog` and `tables`.
2106    /// See `docs/15-credential-enforcement.md`.
2107    principal: RwLock<Option<crate::auth::Principal>>,
2108    /// Shared, cloneable handle to the auth state (require_auth flag from the
2109    /// catalog + the principal). Cloned into every mounted `Table` so the
2110    /// Table layer can enforce permissions without holding a reference back
2111    /// to `Database` (which would create a cycle). `AuthState` is already
2112    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
2113    auth_state: crate::auth_state::AuthState,
2114    /// `true` when this value is a manager-issued facade over a shared core
2115    /// (Stage 1A): dropping it has no storage side effects, `shutdown()`
2116    /// rejects, and auth-mode transitions are refused so one handle cannot
2117    /// flip the shared core into an enforcement mode other handles cannot
2118    /// observe (fail closed; per-handle enforcement lands with Stage 1D
2119    /// sessions).
2120    shared: bool,
2121}
2122
2123impl std::ops::Deref for Database {
2124    type Target = DatabaseCore;
2125
2126    fn deref(&self) -> &DatabaseCore {
2127        &self.core
2128    }
2129}
2130
2131struct RunPins {
2132    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
2133    runs: Vec<(u64, u128)>,
2134}
2135
2136/// RAII proof that one transaction's lock holds release when the scope that
2137/// acquired them exits (S1B-004 step 12). Used by the commit path (covers
2138/// success, validation failure, and poison alike) and by DDL entry points
2139/// holding the Exclusive schema barrier.
2140struct TxnLockGuard<'a> {
2141    locks: &'a crate::locks::LockManager,
2142    txn_id: u64,
2143}
2144
2145impl Drop for TxnLockGuard<'_> {
2146    fn drop(&mut self) {
2147        self.locks.release_all(self.txn_id);
2148    }
2149}
2150
2151struct BackupFilePins {
2152    root: PathBuf,
2153}
2154
2155struct PendingTableDir {
2156    path: PathBuf,
2157    armed: bool,
2158}
2159
2160impl PendingTableDir {
2161    fn new(path: PathBuf) -> Self {
2162        Self { path, armed: true }
2163    }
2164
2165    fn disarm(&mut self) {
2166        self.armed = false;
2167    }
2168}
2169
2170impl Drop for PendingTableDir {
2171    fn drop(&mut self) {
2172        if self.armed {
2173            let _ = std::fs::remove_dir_all(&self.path);
2174        }
2175    }
2176}
2177
2178impl Drop for BackupFilePins {
2179    fn drop(&mut self) {
2180        let _ = std::fs::remove_dir_all(&self.root);
2181    }
2182}
2183
2184impl Drop for RunPins {
2185    fn drop(&mut self) {
2186        let mut pins = self.pins.lock();
2187        for run in &self.runs {
2188            if let Some(count) = pins.get_mut(run) {
2189                *count -= 1;
2190                if *count == 0 {
2191                    pins.remove(run);
2192                }
2193            }
2194        }
2195    }
2196}
2197
2198/// A durable data-change event reconstructed from committed WAL records, or an
2199/// ephemeral SQL `NOTIFY` event when `id` is `None`.
2200#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2201pub struct ChangeEvent {
2202    pub id: Option<String>,
2203    pub channel: String,
2204    pub table_id: Option<u64>,
2205    pub table: String,
2206    pub op: String,
2207    pub epoch: u64,
2208    pub txn_id: Option<u64>,
2209    pub message: Option<String>,
2210    pub data: Option<serde_json::Value>,
2211}
2212
2213#[derive(Debug, Clone)]
2214pub struct CdcBatch {
2215    pub events: Vec<ChangeEvent>,
2216    pub current_epoch: u64,
2217    pub earliest_epoch: Option<u64>,
2218    pub gap: bool,
2219}
2220
2221/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
2222/// (root, epoch, table count, encryption/auth state) without requiring every
2223/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
2224/// The raw field types carry locks, trait objects, and channels that have no
2225/// useful `Debug` output, so a hand-written impl is clearer than peppering
2226/// `#[allow(dead_code)]` skip attributes across two dozen fields.
2227impl std::fmt::Debug for Database {
2228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2229        let cat = self.catalog.read();
2230        let principal_guard = self.principal.read();
2231        let principal: &str = principal_guard
2232            .as_ref()
2233            .map(|p| p.username.as_str())
2234            .unwrap_or("<none>");
2235        f.debug_struct("Database")
2236            .field("root", &self.root)
2237            .field("db_epoch", &cat.db_epoch)
2238            .field("open_generation", &"sidecar")
2239            .field("tables", &cat.tables.len())
2240            .field("visible_epoch", &self.epoch.visible().0)
2241            .field("encrypted", &self.kek.is_some())
2242            .field("require_auth", &cat.require_auth)
2243            .field("principal", &principal)
2244            .finish()
2245    }
2246}
2247
2248/// Manual `Debug` for `DatabaseCore`, mirroring `Database`'s (see above). The
2249/// core has no cached principal by design (spec §4.6).
2250impl std::fmt::Debug for DatabaseCore {
2251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2252        let cat = self.catalog.read();
2253        f.debug_struct("DatabaseCore")
2254            .field("root", &self.root)
2255            .field("lifecycle", &self.lifecycle.state())
2256            .field("db_epoch", &cat.db_epoch)
2257            .field("tables", &cat.tables.len())
2258            .field("visible_epoch", &self.epoch.visible().0)
2259            .field("encrypted", &self.kek.is_some())
2260            .field("require_auth", &cat.require_auth)
2261            .finish()
2262    }
2263}
2264
2265impl DatabaseCore {
2266    fn ensure_owner_process(&self) -> Result<()> {
2267        let current_pid = std::process::id();
2268        let owner_pid = self
2269            ._lock
2270            .lock()
2271            .as_ref()
2272            .map(|lease| lease.owner_pid)
2273            .unwrap_or(current_pid);
2274        if current_pid == owner_pid {
2275            Ok(())
2276        } else {
2277            Err(MongrelError::ForkedProcess {
2278                owner_pid,
2279                current_pid,
2280            })
2281        }
2282    }
2283
2284    /// The canonical filesystem root this core was opened at.
2285    pub fn root(&self) -> &Path {
2286        self.durable_root.canonical_path()
2287    }
2288
2289    /// The stable directory-handle identity of this core's root (S1A-003).
2290    pub fn file_identity(&self) -> Result<crate::core::DatabaseFileIdentity> {
2291        crate::core::DatabaseFileIdentity::from_durable_root(&self.durable_root)
2292    }
2293
2294    /// The current lifecycle state (S1A-004).
2295    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2296        self.lifecycle.state()
2297    }
2298
2299    /// Whether this core currently admits new operations.
2300    pub fn is_open(&self) -> bool {
2301        self.lifecycle.is_open()
2302    }
2303
2304    /// Admit one operation against this core (S1A-004: "every operation holds
2305    /// an `OperationGuard`"). Rejected unless the core is
2306    /// [`crate::core::LifecycleState::Open`].
2307    pub fn operation_guard(self: &Arc<Self>) -> Result<crate::core::OperationGuard> {
2308        self.lifecycle.begin_operation()
2309    }
2310
2311    /// Register this core with the [`crate::manager::DatabaseManager`] that
2312    /// initialized it (S1A-002). Called once, before the first handle is
2313    /// handed out.
2314    pub(crate) fn set_registry(
2315        &self,
2316        registration: crate::manager::CoreRegistration,
2317    ) -> Result<()> {
2318        self.registry
2319            .set(registration)
2320            .map_err(|_| MongrelError::Conflict("database core is already registry-bound".into()))
2321    }
2322
2323    /// Ordered core shutdown (spec §10.1, S1A-004):
2324    ///
2325    /// 1. Transition `Open` → `Draining`.
2326    /// 2. Reject new sessions and writes (new [`Self::operation_guard`]s fail
2327    ///    from this point).
2328    /// 3. Cancel non-commit-critical queries — a no-op in Stage 1A: the
2329    ///    embedded core has no central query registry yet (Stage 1D adds one),
2330    ///    and every in-flight operation is treated as commit-critical and
2331    ///    drained rather than cancelled.
2332    /// 4. Wait for in-flight operations, up to `drain_deadline`. On timeout
2333    ///    the core stays `Draining` and a later call may resume the shutdown.
2334    /// 5. Sync required durable state (group-sync the shared WAL).
2335    /// 6. Stop workers — the Stage 1A core runs no background worker set.
2336    /// 7. Release the file lock (and the process open-registry entries).
2337    /// 8. Mark `Closed`.
2338    ///
2339    /// Repeated calls after `Closed` return `Ok(())`. Dropping one handle has
2340    /// no storage side effects; only this method (or the last `Arc` drop)
2341    /// closes storage.
2342    pub fn shutdown(self: &Arc<Self>, drain_deadline: std::time::Duration) -> Result<()> {
2343        self.ensure_owner_process()?;
2344        let initiated = self.lifecycle.begin_shutdown();
2345        if !initiated {
2346            match self.lifecycle.state() {
2347                crate::core::LifecycleState::Closed => return Ok(()),
2348                // A concurrent or timed-out shutdown left the core draining;
2349                // join it and drive the remaining steps.
2350                crate::core::LifecycleState::Draining => {}
2351                state => {
2352                    return Err(MongrelError::Conflict(format!(
2353                        "database core cannot shut down from lifecycle state {state}"
2354                    )))
2355                }
2356            }
2357        }
2358        if let Some(registration) = self.registry.get() {
2359            registration
2360                .manager
2361                .mark_closing(&registration.identity, self);
2362        }
2363        self.lifecycle.wait_drained(drain_deadline)?;
2364        let sync = self.shared_wal.lock().group_sync().map(|_| ());
2365        self.lifecycle.mark_closing();
2366        let lease = self._lock.lock().take();
2367        drop(lease);
2368        if let Some(registration) = self.registry.get() {
2369            registration
2370                .manager
2371                .entry_closed(&registration.identity, self);
2372        }
2373        self.lifecycle.mark_closed();
2374        sync
2375    }
2376}
2377
2378impl Database {
2379    pub fn open_metrics() -> DatabaseOpenMetrics {
2380        DatabaseOpenMetrics {
2381            lock_waits: DATABASE_OPEN_WAIT_COUNT.load(Ordering::Relaxed),
2382            failures: DATABASE_OPEN_FAILURE_COUNT.load(Ordering::Relaxed),
2383        }
2384    }
2385
2386    /// The storage core this owner references (spec §10.1, S1A-001).
2387    pub fn core(&self) -> Arc<DatabaseCore> {
2388        Arc::clone(&self.core)
2389    }
2390
2391    /// The identity bound to this owner (spec §10.1, S1A-001): a catalog user
2392    /// for credentialed opens, `Credentialless` otherwise.
2393    pub fn identity(&self) -> crate::handle::HandleIdentity {
2394        match self.principal.read().as_ref() {
2395            Some(principal) => crate::handle::HandleIdentity::CatalogUser {
2396                username: principal.username.clone(),
2397                user_id: principal.user_id,
2398                created_version: principal.created_epoch,
2399            },
2400            None => crate::handle::HandleIdentity::Credentialless,
2401        }
2402    }
2403
2404    /// The current lifecycle state of the underlying storage core (S1A-004).
2405    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2406        self.core.lifecycle_state()
2407    }
2408
2409    /// Admit one operation against the storage core (S1A-004). The returned
2410    /// RAII guard releases the operation slot on drop; new operations are
2411    /// rejected once the core leaves [`crate::core::LifecycleState::Open`].
2412    pub fn operation_guard(&self) -> Result<crate::core::OperationGuard> {
2413        self.core.operation_guard()
2414    }
2415
2416    /// Build an owner facade over an existing shared core. Used by
2417    /// [`crate::manager::DatabaseManager`] to back [`crate::handle::DatabaseHandle`]s;
2418    /// exclusive `Database::open*` constructors build their own core instead.
2419    pub(crate) fn from_core(
2420        core: Arc<DatabaseCore>,
2421        principal: Option<crate::auth::Principal>,
2422        shared: bool,
2423    ) -> Self {
2424        let require_auth = core.catalog.read().require_auth;
2425        Self {
2426            core,
2427            principal: RwLock::new(principal.clone()),
2428            auth_state: crate::auth_state::AuthState::new(require_auth, principal),
2429            shared,
2430        }
2431    }
2432
2433    /// Rebind this facade's principal to the live service-principal definition
2434    /// (P0.1). Called by [`crate::handle::DatabaseHandle`] after each
2435    /// `resolve_live` so subsequent require/txn checks see current scopes.
2436    pub(crate) fn rebind_service_principal(
2437        &self,
2438        def: &crate::service_principal::ServicePrincipalDefinition,
2439    ) {
2440        let principal = crate::auth::Principal {
2441            user_id: 0,
2442            created_epoch: def.creation_version,
2443            username: format!("service:{}", def.token_id),
2444            is_admin: false,
2445            roles: Vec::new(),
2446            permissions: def.permissions.clone(),
2447        };
2448        *self.principal.write() = Some(principal.clone());
2449        self.auth_state.set_principal(Some(principal));
2450    }
2451
2452    /// Consume this owner and yield the shared core (keeps the core alive).
2453    pub(crate) fn into_core(self) -> Arc<DatabaseCore> {
2454        self.core
2455    }
2456
2457    /// Open an existing plaintext database as the one core backing shared
2458    /// handles (spec §10.1, S1A-002). Recovery, WAL opening, open-generation
2459    /// advancement, and table mounting all happen inside this call — exactly
2460    /// once per core.
2461    pub(crate) fn open_for_shared_core(root: impl AsRef<Path>) -> Result<Self> {
2462        Self::open_inner_with_lock_timeout(
2463            root,
2464            None,
2465            None,
2466            0,
2467            CoreResourceConfig::default(),
2468            OpenModeGate::SharedCore,
2469        )
2470    }
2471
2472    /// Explicitly close the final shared database owner.
2473    ///
2474    /// On a manager-issued facade (`shared`) this rejects: dropping one handle
2475    /// never closes storage while another handle exists, and shared cores are
2476    /// shut down explicitly via [`crate::handle::DatabaseHandle::shutdown`].
2477    pub fn shutdown(self: Arc<Self>) -> Result<()> {
2478        if self.shared {
2479            return Err(MongrelError::Conflict(
2480                "shared-core facades do not own storage; use DatabaseHandle::shutdown".into(),
2481            ));
2482        }
2483        match Arc::try_unwrap(self) {
2484            Ok(database) => {
2485                database.ensure_owner_process()?;
2486                // Drain + close the exclusively-owned core (S1A-004 steps:
2487                // reject new operations, wait for the drain, sync durable
2488                // state, release the file lock, mark Closed), then drop it.
2489                database.core.shutdown(std::time::Duration::from_secs(30))?;
2490                drop(database);
2491                Ok(())
2492            }
2493            Err(database) => Err(MongrelError::DatabaseBusy {
2494                strong_handles: Arc::strong_count(&database),
2495            }),
2496        }
2497    }
2498
2499    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
2500        if let Ok(canonical) = root.canonicalize() {
2501            let lock_dir = canonical.parent().ok_or_else(|| {
2502                std::io::Error::new(
2503                    std::io::ErrorKind::InvalidInput,
2504                    "database root must have a parent directory",
2505                )
2506            })?;
2507            return Ok((canonical.clone(), lock_dir.to_path_buf()));
2508        }
2509
2510        let absolute = if root.is_absolute() {
2511            root.to_path_buf()
2512        } else {
2513            std::env::current_dir()?.join(root)
2514        };
2515        let mut cursor = absolute.as_path();
2516        let mut suffix = Vec::new();
2517        while !cursor.exists() {
2518            let name = cursor.file_name().ok_or_else(|| {
2519                std::io::Error::new(
2520                    std::io::ErrorKind::NotFound,
2521                    format!("no existing ancestor for database root {}", root.display()),
2522                )
2523            })?;
2524            suffix.push(name.to_os_string());
2525            cursor = cursor.parent().ok_or_else(|| {
2526                std::io::Error::new(
2527                    std::io::ErrorKind::NotFound,
2528                    format!("no existing ancestor for database root {}", root.display()),
2529                )
2530            })?;
2531        }
2532        let lock_dir = cursor.canonicalize()?;
2533        let mut canonical = lock_dir.clone();
2534        for component in suffix.iter().rev() {
2535            canonical.push(component);
2536        }
2537        Ok((canonical, lock_dir))
2538    }
2539
2540    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<ExclusiveDatabaseLease> {
2541        use std::hash::{Hash, Hasher};
2542
2543        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
2544        let reservation =
2545            OpenReservation::acquire(DatabaseOpenKey::IntendedPath(canonical_path.clone()), root)?;
2546
2547        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2548        canonical_path.hash(&mut hasher);
2549        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
2550        let file = std::fs::OpenOptions::new()
2551            .create(true)
2552            .truncate(false)
2553            .write(true)
2554            .open(lock_path)?;
2555        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2556            return Err(MongrelError::DatabaseLocked {
2557                path: root.to_path_buf(),
2558                message: error.to_string(),
2559            });
2560        }
2561        Ok(reservation.into_lease(file, canonical_path))
2562    }
2563
2564    fn acquire_legacy_database_lock(
2565        lock: &mut ExclusiveDatabaseLease,
2566        root: &Path,
2567        timeout_ms: u32,
2568    ) -> Result<()> {
2569        let durable_root = lock
2570            .durable_root
2571            .as_ref()
2572            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
2573        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
2574        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2575            return Err(MongrelError::DatabaseLocked {
2576                path: root.to_path_buf(),
2577                message: error.to_string(),
2578            });
2579        }
2580        lock.legacy_file = Some(file);
2581        Ok(())
2582    }
2583
2584    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
2585        if path.exists() {
2586            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
2587        }
2588        let mut ancestor = path;
2589        while !ancestor.exists() {
2590            ancestor = ancestor.parent().ok_or_else(|| {
2591                MongrelError::NotFound(format!(
2592                    "no existing ancestor for database root {}",
2593                    path.display()
2594                ))
2595            })?;
2596        }
2597        let relative = path.strip_prefix(ancestor).map_err(|error| {
2598            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
2599        })?;
2600        crate::durable_file::DurableRoot::open(ancestor)?
2601            .create_directory_all_pinned(relative)
2602            .map_err(Into::into)
2603    }
2604
2605    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2606        let requested_root = root.as_ref();
2607        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
2608        let root = lock.canonical_path.clone();
2609        Self::reject_existing_database(&root)?;
2610        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
2611        if durable_root.canonical_path() != lock.canonical_path {
2612            return Err(MongrelError::Conflict(
2613                "database root changed while it was being created".into(),
2614            ));
2615        }
2616        lock.claim_root_identity(&durable_root)?;
2617        durable_root.create_directory_all(META_DIR)?;
2618        lock.durable_root = Some(durable_root);
2619        let io_root = lock
2620            .durable_root
2621            .as_ref()
2622            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2623            .io_path()?;
2624        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
2625        Self::reject_existing_database(&io_root)?;
2626        Ok((io_root, lock))
2627    }
2628
2629    fn begin_open(
2630        root: impl AsRef<Path>,
2631        lock_timeout_ms: u32,
2632    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2633        let root = root.as_ref();
2634        let canonical_root = root.canonicalize().map_err(|error| {
2635            if error.kind() == std::io::ErrorKind::NotFound {
2636                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
2637            } else {
2638                error.into()
2639            }
2640        })?;
2641        let durable_root = crate::durable_file::DurableRoot::open(&canonical_root)?;
2642        Self::begin_open_durable(durable_root, lock_timeout_ms)
2643    }
2644
2645    fn begin_open_durable(
2646        durable_root: crate::durable_file::DurableRoot,
2647        lock_timeout_ms: u32,
2648    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2649        let io_root = durable_root.io_path()?;
2650        let current_root = io_root.canonicalize()?;
2651        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
2652        lock.claim_root_identity(&durable_root)?;
2653        lock.durable_root = Some(Arc::new(durable_root));
2654        let io_root = lock
2655            .durable_root
2656            .as_ref()
2657            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2658            .io_path()?;
2659        if lock
2660            .durable_root
2661            .as_ref()
2662            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2663            .open_directory(META_DIR)
2664            .is_err()
2665        {
2666            return Err(MongrelError::NotFound(format!(
2667                "no database metadata found at {:?}",
2668                current_root
2669            )));
2670        }
2671        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
2672        Ok((io_root, lock))
2673    }
2674
2675    /// Create a fresh plaintext database at `root`.
2676    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
2677        let (root, lock) = Self::begin_create(root)?;
2678        Self::create_inner(
2679            root,
2680            None,
2681            lock,
2682            crate::storage_mode::StorageMode::Standalone,
2683        )
2684    }
2685
2686    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
2687    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
2688    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2689        let (root, lock) = Self::begin_create(root)?;
2690        let salt = crate::encryption::random_salt()?;
2691        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2692        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2693        Self::create_inner(
2694            root,
2695            Some(kek),
2696            lock,
2697            crate::storage_mode::StorageMode::Standalone,
2698        )
2699    }
2700
2701    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
2702    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
2703    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2704        let (root, lock) = Self::begin_create(root)?;
2705        let salt = crate::encryption::random_salt()?;
2706        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2707        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2708        Self::create_inner(
2709            root,
2710            Some(kek),
2711            lock,
2712            crate::storage_mode::StorageMode::Standalone,
2713        )
2714    }
2715
2716    /// Create a fresh encrypted database whose random root key is wrapped by
2717    /// an external KMS provider.
2718    pub fn create_with_kms(
2719        root: impl AsRef<Path>,
2720        provider: &dyn crate::security_hardening::KeyManagementProvider,
2721        kms_key_id: &str,
2722    ) -> Result<Self> {
2723        let (root, lock) = Self::begin_create(root)?;
2724        let kek = create_kms_kek(&root, provider, kms_key_id)?;
2725        Self::create_inner(
2726            root,
2727            Some(kek),
2728            lock,
2729            crate::storage_mode::StorageMode::Standalone,
2730        )
2731    }
2732
2733    /// Create a fresh plaintext database owned by the cluster node runtime
2734    /// (spec section 5.3): the durable marker records
2735    /// [`crate::storage_mode::StorageMode::ClusterReplica`], so normal
2736    /// `Database::open*` paths reject the directory and only
2737    /// [`Database::open_cluster_replica`] (or the read-only offline validator)
2738    /// can open it afterwards. The returned core rejects user writes: every
2739    /// mutation arrives through the replicated apply path (Stage 2E).
2740    pub fn create_cluster_replica(
2741        root: impl AsRef<Path>,
2742        cluster_id: mongreldb_types::ids::ClusterId,
2743        node_id: mongreldb_types::ids::NodeId,
2744        database_id: mongreldb_types::ids::DatabaseId,
2745    ) -> Result<Self> {
2746        let (root, lock) = Self::begin_create(root)?;
2747        Self::create_inner(
2748            root,
2749            None,
2750            lock,
2751            crate::storage_mode::StorageMode::ClusterReplica {
2752                cluster_id,
2753                node_id,
2754                database_id,
2755            },
2756        )
2757    }
2758
2759    /// Open a cluster-replica database as the cluster node runtime (spec
2760    /// section 5.3). Fails closed unless the durable marker exists and exactly
2761    /// matches `expected` (a `ClusterReplica` identity): opening a replica of
2762    /// the wrong cluster or database would corrupt both. The opened core
2763    /// rejects user writes with [`MongrelError::ReadOnlyReplica`]; mutations
2764    /// arrive through the replicated apply path (Stage 2E).
2765    pub fn open_cluster_replica(
2766        root: impl AsRef<Path>,
2767        expected: &crate::storage_mode::StorageMode,
2768    ) -> Result<Self> {
2769        let Some((cluster_id, node_id, database_id)) = expected.cluster_identity() else {
2770            return Err(MongrelError::InvalidArgument(format!(
2771                "open_cluster_replica requires a ClusterReplica identity, got {expected:?}"
2772            )));
2773        };
2774        let (root, lock) = Self::begin_open(root, 0)?;
2775        Self::open_inner_locked(
2776            root,
2777            None,
2778            lock,
2779            CoreResourceConfig::default(),
2780            OpenModeGate::ClusterRuntime {
2781                cluster_id,
2782                node_id,
2783                database_id,
2784            },
2785        )
2786    }
2787
2788    fn create_inner(
2789        root: PathBuf,
2790        kek: Option<Arc<crate::encryption::Kek>>,
2791        lock: ExclusiveDatabaseLease,
2792        storage_mode: crate::storage_mode::StorageMode,
2793    ) -> Result<Self> {
2794        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2795        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2796        let cat = Catalog::empty();
2797        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2798        Self::finish_open(
2799            root,
2800            cat,
2801            kek,
2802            meta_dek,
2803            false,
2804            None,
2805            None,
2806            None,
2807            lock,
2808            CoreResourceConfig::default(),
2809            OpenModeGate::Create(storage_mode),
2810        )
2811    }
2812
2813    /// Open an existing plaintext database.
2814    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
2815        Self::open_inner(root, None, None)
2816    }
2817
2818    /// Open an existing encrypted database with a passphrase.
2819    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2820        let (root, lock) = Self::begin_open(root, 0)?;
2821        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2822            MongrelError::Other("database root descriptor was not pinned".into())
2823        })?)?;
2824        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2825        Self::open_inner_locked(
2826            root,
2827            Some(kek),
2828            lock,
2829            CoreResourceConfig::default(),
2830            OpenModeGate::Normal,
2831        )
2832    }
2833
2834    /// Open an existing encrypted database with a configurable cross-process
2835    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
2836    pub fn open_encrypted_with_options(
2837        root: impl AsRef<Path>,
2838        passphrase: &str,
2839        options: OpenOptions,
2840    ) -> Result<Self> {
2841        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2842        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2843            MongrelError::Other("database root descriptor was not pinned".into())
2844        })?)?;
2845        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2846        let gate = if options.offline_validation {
2847            OpenModeGate::OfflineValidation
2848        } else {
2849            OpenModeGate::Normal
2850        };
2851        Self::open_inner_locked(
2852            root,
2853            Some(kek),
2854            lock,
2855            CoreResourceConfig::from_options(&options)?,
2856            gate,
2857        )
2858    }
2859
2860    /// Open an existing encrypted database using a raw high-entropy key.
2861    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2862        let (root, lock) = Self::begin_open(root, 0)?;
2863        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2864            MongrelError::Other("database root descriptor was not pinned".into())
2865        })?)?;
2866        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2867        Self::open_inner_locked(
2868            root,
2869            Some(kek),
2870            lock,
2871            CoreResourceConfig::default(),
2872            OpenModeGate::Normal,
2873        )
2874    }
2875
2876    /// Open a KMS-encrypted database. Provider identity must match the durable
2877    /// envelope and unwrapping fails closed.
2878    pub fn open_with_kms(
2879        root: impl AsRef<Path>,
2880        provider: &dyn crate::security_hardening::KeyManagementProvider,
2881    ) -> Result<Self> {
2882        Self::open_with_kms_and_options(root, provider, OpenOptions::default())
2883    }
2884
2885    /// Open a KMS-encrypted database with non-default open options.
2886    pub fn open_with_kms_and_options(
2887        root: impl AsRef<Path>,
2888        provider: &dyn crate::security_hardening::KeyManagementProvider,
2889        options: OpenOptions,
2890    ) -> Result<Self> {
2891        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2892        let kek = open_kms_kek(
2893            lock.durable_root.as_deref().ok_or_else(|| {
2894                MongrelError::Other("database root descriptor was not pinned".into())
2895            })?,
2896            provider,
2897        )?;
2898        let gate = if options.offline_validation {
2899            OpenModeGate::OfflineValidation
2900        } else {
2901            OpenModeGate::Normal
2902        };
2903        Self::open_inner_locked(
2904            root,
2905            Some(kek),
2906            lock,
2907            CoreResourceConfig::from_options(&options)?,
2908            gate,
2909        )
2910    }
2911
2912    /// Rewrap the database root key under another KMS key without stopping
2913    /// reads or writes. Data files stay encrypted under the same random root
2914    /// key; only its external envelope changes.
2915    pub fn rotate_kms_key(
2916        &self,
2917        provider: &dyn crate::security_hardening::KeyManagementProvider,
2918        new_kms_key_id: &str,
2919    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2920        let _rotation_guard = self.core.kms_rotation_lock.lock();
2921        if new_kms_key_id.is_empty() {
2922            return Err(MongrelError::InvalidArgument(
2923                "KMS key id must not be empty".into(),
2924            ));
2925        }
2926        let durable_root = Arc::clone(&self.core.durable_root);
2927        let root = durable_root.io_path()?;
2928        let envelope = read_kms_key_envelope(&durable_root)?;
2929        if envelope.provider_id != provider.provider_id() {
2930            return Err(MongrelError::Encryption(format!(
2931                "KMS provider mismatch: database requires {:?}, got {:?}",
2932                envelope.provider_id,
2933                provider.provider_id()
2934            )));
2935        }
2936        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2937        if let Some(record) = journal
2938            .load()
2939            .map_err(|error| MongrelError::Encryption(error.to_string()))?
2940            .filter(|record| record.phase != crate::security_hardening::KeyRotationPhase::Succeeded)
2941        {
2942            if record.to_key_id != new_kms_key_id {
2943                return Err(MongrelError::Conflict(format!(
2944                    "KMS rotation to {:?} is already in progress",
2945                    record.to_key_id
2946                )));
2947            }
2948            return self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record);
2949        }
2950        let now_micros = std::time::SystemTime::now()
2951            .duration_since(std::time::UNIX_EPOCH)
2952            .unwrap_or_default()
2953            .as_micros() as u64;
2954        let mut record = crate::security_hardening::KeyRotationRecord::new(
2955            envelope.wrapped_key.key_version.clone(),
2956            String::new(),
2957            now_micros,
2958        );
2959        record.from_key_id = envelope.wrapped_key.kms_key_id.clone();
2960        record.to_key_id = new_kms_key_id.to_owned();
2961        persist_key_rotation(&journal, &record)?;
2962        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
2963    }
2964
2965    /// Retry a rotation that durably entered `Failed`.
2966    pub fn retry_kms_key_rotation(
2967        &self,
2968        provider: &dyn crate::security_hardening::KeyManagementProvider,
2969    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2970        let _rotation_guard = self.core.kms_rotation_lock.lock();
2971        let durable_root = Arc::clone(&self.core.durable_root);
2972        let root = durable_root.io_path()?;
2973        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2974        let mut record = journal
2975            .load()
2976            .map_err(|error| MongrelError::Encryption(error.to_string()))?
2977            .ok_or_else(|| MongrelError::NotFound("KMS rotation journal".into()))?;
2978        if record.phase != crate::security_hardening::KeyRotationPhase::Failed {
2979            return Err(MongrelError::Conflict(
2980                "KMS rotation is not in Failed state".into(),
2981            ));
2982        }
2983        record.retry();
2984        persist_key_rotation(&journal, &record)?;
2985        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
2986    }
2987
2988    fn run_kms_key_rotation(
2989        &self,
2990        provider: &dyn crate::security_hardening::KeyManagementProvider,
2991        durable_root: &crate::durable_file::DurableRoot,
2992        root: &Path,
2993        journal: &crate::security_hardening::KeyRotationJournal,
2994        mut record: crate::security_hardening::KeyRotationRecord,
2995    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2996        use crate::security_hardening::{KeyRotationPhase, KmsDatabaseKeyEnvelope};
2997        loop {
2998            match record.phase {
2999                KeyRotationPhase::Pending => advance_key_rotation(journal, &mut record)?,
3000                KeyRotationPhase::WrappingNewKey => {
3001                    if record.staged_wrapped_key.is_none() {
3002                        let current = read_kms_key_envelope(durable_root)?;
3003                        let wrapped =
3004                            match provider.rewrap_key(&current.wrapped_key, &record.to_key_id) {
3005                                Ok(wrapped) => wrapped,
3006                                Err(error) => {
3007                                    return fail_key_rotation(journal, &mut record, error);
3008                                }
3009                            };
3010                        record.to_version = wrapped.key_version.clone();
3011                        record.staged_wrapped_key = Some(wrapped);
3012                        persist_key_rotation(journal, &record)?;
3013                    }
3014                    advance_key_rotation(journal, &mut record)?;
3015                }
3016                KeyRotationPhase::DualRead => {
3017                    let old_envelope = read_kms_key_envelope(durable_root)?;
3018                    let new_envelope = KmsDatabaseKeyEnvelope {
3019                        provider_id: provider.provider_id().to_owned(),
3020                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
3021                            MongrelError::Encryption(
3022                                "KMS rotation has no staged wrapped key".into(),
3023                            )
3024                        })?,
3025                    };
3026                    let old_raw = match unwrap_kms_database_key(provider, &old_envelope) {
3027                        Ok(key) => key,
3028                        Err(error) => {
3029                            return fail_key_rotation(journal, &mut record, error);
3030                        }
3031                    };
3032                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
3033                        Ok(key) => key,
3034                        Err(error) => {
3035                            return fail_key_rotation(journal, &mut record, error);
3036                        }
3037                    };
3038                    if !bool::from(old_raw.as_slice().ct_eq(new_raw.as_slice())) {
3039                        return fail_key_rotation(
3040                            journal,
3041                            &mut record,
3042                            "KMS rewrap changed the database root key",
3043                        );
3044                    }
3045                    advance_key_rotation(journal, &mut record)?;
3046                }
3047                KeyRotationPhase::Reencrypting => {
3048                    // Envelope rotation keeps the database root key stable, so
3049                    // pages, WAL records, and active readers need no rewrite.
3050                    advance_key_rotation(journal, &mut record)?;
3051                }
3052                KeyRotationPhase::Validating => {
3053                    let new_envelope = KmsDatabaseKeyEnvelope {
3054                        provider_id: provider.provider_id().to_owned(),
3055                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
3056                            MongrelError::Encryption(
3057                                "KMS rotation has no staged wrapped key".into(),
3058                            )
3059                        })?,
3060                    };
3061                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
3062                        Ok(key) => key,
3063                        Err(error) => {
3064                            return fail_key_rotation(journal, &mut record, error);
3065                        }
3066                    };
3067                    let salt = read_encryption_salt(durable_root)?;
3068                    let candidate = crate::encryption::Kek::from_raw_key(new_raw.as_ref(), &salt)?;
3069                    let candidate_meta = candidate.derive_meta_key();
3070                    let Some(active_meta) = self.core.meta_dek.as_ref() else {
3071                        return fail_key_rotation(
3072                            journal,
3073                            &mut record,
3074                            "KMS metadata exists on a plaintext database",
3075                        );
3076                    };
3077                    if !bool::from(candidate_meta.as_ref().ct_eq(active_meta.as_slice())) {
3078                        return fail_key_rotation(
3079                            journal,
3080                            &mut record,
3081                            "KMS rewrap validation failed",
3082                        );
3083                    }
3084                    advance_key_rotation(journal, &mut record)?;
3085                }
3086                KeyRotationPhase::Published => {
3087                    let envelope = KmsDatabaseKeyEnvelope {
3088                        provider_id: provider.provider_id().to_owned(),
3089                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
3090                            MongrelError::Encryption(
3091                                "KMS rotation has no staged wrapped key".into(),
3092                            )
3093                        })?,
3094                    };
3095                    write_kms_key_envelope(root, &envelope)?;
3096                    advance_key_rotation(journal, &mut record)?;
3097                }
3098                KeyRotationPhase::RetiringOldKey => {
3099                    advance_key_rotation(journal, &mut record)?;
3100                }
3101                KeyRotationPhase::Succeeded => return Ok(record),
3102                KeyRotationPhase::Failed => {
3103                    return Err(MongrelError::Encryption(
3104                        "failed KMS rotation must be explicitly retried".into(),
3105                    ));
3106                }
3107            }
3108        }
3109    }
3110
3111    /// Open an existing plaintext database that has `require_auth = true`,
3112    /// verifying the supplied credentials up front and caching the resolved
3113    /// [`Principal`] on the returned handle. Every subsequent operation will
3114    /// be checked against that principal.
3115    ///
3116    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
3117    /// `require_auth` enabled — callers must pick the matching constructor for
3118    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
3119    /// bad username/password.
3120    ///
3121    /// See `docs/15-credential-enforcement.md`.
3122    pub fn open_with_credentials(
3123        root: impl AsRef<Path>,
3124        username: &str,
3125        password: &str,
3126    ) -> Result<Self> {
3127        Self::open_inner_with_credentials(root, None, username, password)
3128    }
3129
3130    /// Open with credentials and a configurable cross-process lock timeout.
3131    /// Mirrors [`open_with_options`](Self::open_with_options) for the
3132    /// credentialed path.
3133    pub fn open_with_credentials_and_options(
3134        root: impl AsRef<Path>,
3135        username: &str,
3136        password: &str,
3137        options: OpenOptions,
3138    ) -> Result<Self> {
3139        let gate = if options.offline_validation {
3140            OpenModeGate::OfflineValidation
3141        } else {
3142            OpenModeGate::Normal
3143        };
3144        Self::open_inner_with_credentials_and_lock_timeout(
3145            root,
3146            None,
3147            username,
3148            password,
3149            options.lock_timeout_ms,
3150            CoreResourceConfig::from_options(&options)?,
3151            gate,
3152        )
3153    }
3154
3155    /// Open an existing encrypted database that has `require_auth = true`,
3156    /// combining the encryption passphrase flow with credential verification.
3157    pub fn open_encrypted_with_credentials(
3158        root: impl AsRef<Path>,
3159        passphrase: &str,
3160        username: &str,
3161        password: &str,
3162    ) -> Result<Self> {
3163        let (root, lock) = Self::begin_open(root, 0)?;
3164        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3165            MongrelError::Other("database root descriptor was not pinned".into())
3166        })?)?;
3167        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3168        Self::open_inner_with_credentials_locked(
3169            root,
3170            Some(kek),
3171            username,
3172            password,
3173            lock,
3174            CoreResourceConfig::default(),
3175            OpenModeGate::Normal,
3176        )
3177    }
3178
3179    /// Open an encrypted + credentialed database with a configurable
3180    /// cross-process lock timeout. Mirrors
3181    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
3182    pub fn open_encrypted_with_credentials_and_options(
3183        root: impl AsRef<Path>,
3184        passphrase: &str,
3185        username: &str,
3186        password: &str,
3187        options: OpenOptions,
3188    ) -> Result<Self> {
3189        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
3190        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3191            MongrelError::Other("database root descriptor was not pinned".into())
3192        })?)?;
3193        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3194        let gate = if options.offline_validation {
3195            OpenModeGate::OfflineValidation
3196        } else {
3197            OpenModeGate::Normal
3198        };
3199        Self::open_inner_with_credentials_locked(
3200            root,
3201            Some(kek),
3202            username,
3203            password,
3204            lock,
3205            CoreResourceConfig::from_options(&options)?,
3206            gate,
3207        )
3208    }
3209
3210    /// Open a KMS-encrypted database and authenticate the returned handle.
3211    pub fn open_with_kms_and_credentials(
3212        root: impl AsRef<Path>,
3213        provider: &dyn crate::security_hardening::KeyManagementProvider,
3214        username: &str,
3215        password: &str,
3216    ) -> Result<Self> {
3217        let (root, lock) = Self::begin_open(root, 0)?;
3218        let kek = open_kms_kek(
3219            lock.durable_root.as_deref().ok_or_else(|| {
3220                MongrelError::Other("database root descriptor was not pinned".into())
3221            })?,
3222            provider,
3223        )?;
3224        Self::open_inner_with_credentials_locked(
3225            root,
3226            Some(kek),
3227            username,
3228            password,
3229            lock,
3230            CoreResourceConfig::default(),
3231            OpenModeGate::Normal,
3232        )
3233    }
3234
3235    /// Open an existing database with non-default [`OpenOptions`].
3236    ///
3237    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
3238    /// rather than the fail-fast default. The other open constructors keep
3239    /// their previous defaults; use their `*_with_options` variants when they
3240    /// need the same timeout behavior.
3241    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
3242        // No encryption, no auth; encrypted and credentialed paths have their
3243        // own `*_with_options` constructors.
3244        let gate = if options.offline_validation {
3245            OpenModeGate::OfflineValidation
3246        } else {
3247            OpenModeGate::Normal
3248        };
3249        Self::open_inner_with_lock_timeout(
3250            root,
3251            None,
3252            None,
3253            options.lock_timeout_ms,
3254            CoreResourceConfig::from_options(&options)?,
3255            gate,
3256        )
3257    }
3258
3259    fn open_inner_with_lock_timeout(
3260        root: impl AsRef<Path>,
3261        kek: Option<Arc<crate::encryption::Kek>>,
3262        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3263        lock_timeout_ms: u32,
3264        resources: CoreResourceConfig,
3265        mode_gate: OpenModeGate,
3266    ) -> Result<Self> {
3267        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3268        Self::open_inner_locked(root, kek, lock, resources, mode_gate)
3269    }
3270
3271    fn open_inner_locked(
3272        root: PathBuf,
3273        kek: Option<Arc<crate::encryption::Kek>>,
3274        lock: ExclusiveDatabaseLease,
3275        resources: CoreResourceConfig,
3276        mode_gate: OpenModeGate,
3277    ) -> Result<Self> {
3278        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3279        let mut cat = catalog::read_durable(
3280            lock.durable_root.as_deref().ok_or_else(|| {
3281                MongrelError::Other("database root descriptor was not pinned".into())
3282            })?,
3283            meta_dek.as_ref(),
3284        )?
3285        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3286        let recovery_checkpoint = cat.clone();
3287
3288        // CATALOG is only a checkpoint. Authentication must use the
3289        // authoritative catalog after committed WAL DDL/security replay.
3290        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3291        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3292            lock.durable_root.as_deref().ok_or_else(|| {
3293                MongrelError::Other("database root descriptor was not pinned".into())
3294            })?,
3295            wal_dek.as_ref(),
3296        )?;
3297        recover_ddl_from_records(
3298            &root,
3299            Some(lock.durable_root.as_deref().ok_or_else(|| {
3300                MongrelError::Other("database root descriptor was not pinned".into())
3301            })?),
3302            &mut cat,
3303            meta_dek.as_ref(),
3304            false,
3305            None,
3306            &recovery_records,
3307        )?;
3308        Self::finish_open(
3309            root,
3310            cat,
3311            kek,
3312            meta_dek,
3313            true,
3314            Some(recovery_checkpoint),
3315            Some(recovery_records),
3316            None,
3317            lock,
3318            resources,
3319            mode_gate,
3320        )
3321    }
3322
3323    /// Shared credentialed-open inner: read the catalog, verify the database
3324    /// requires auth, verify the password, resolve the principal, and pass
3325    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
3326    /// problem where `finish_open`'s fail-closed check (`require_auth &&
3327    /// principal.is_none()`) would fire before a post-open `authenticate()`
3328    /// could supply the principal.
3329    fn open_inner_with_credentials(
3330        root: impl AsRef<Path>,
3331        kek: Option<Arc<crate::encryption::Kek>>,
3332        username: &str,
3333        password: &str,
3334    ) -> Result<Self> {
3335        Self::open_inner_with_credentials_and_lock_timeout(
3336            root,
3337            kek,
3338            username,
3339            password,
3340            0,
3341            CoreResourceConfig::default(),
3342            OpenModeGate::Normal,
3343        )
3344    }
3345
3346    /// Credentialed-open with an explicit cross-process lock timeout. The
3347    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
3348    /// historical fail-fast behavior via the wrapper above.
3349    #[allow(clippy::too_many_arguments)]
3350    fn open_inner_with_credentials_and_lock_timeout(
3351        root: impl AsRef<Path>,
3352        kek: Option<Arc<crate::encryption::Kek>>,
3353        username: &str,
3354        password: &str,
3355        lock_timeout_ms: u32,
3356        resources: CoreResourceConfig,
3357        mode_gate: OpenModeGate,
3358    ) -> Result<Self> {
3359        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3360        Self::open_inner_with_credentials_locked(
3361            root, kek, username, password, lock, resources, mode_gate,
3362        )
3363    }
3364
3365    #[allow(clippy::too_many_arguments)]
3366    fn open_inner_with_credentials_locked(
3367        root: PathBuf,
3368        kek: Option<Arc<crate::encryption::Kek>>,
3369        username: &str,
3370        password: &str,
3371        lock: ExclusiveDatabaseLease,
3372        resources: CoreResourceConfig,
3373        mode_gate: OpenModeGate,
3374    ) -> Result<Self> {
3375        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3376        let mut cat = catalog::read_durable(
3377            lock.durable_root.as_deref().ok_or_else(|| {
3378                MongrelError::Other("database root descriptor was not pinned".into())
3379            })?,
3380            meta_dek.as_ref(),
3381        )?
3382        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3383        let recovery_checkpoint = cat.clone();
3384
3385        // Never verify against a stale checkpoint. A committed password,
3386        // user, role, or auth-mode change in WAL is authoritative.
3387        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3388        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3389            lock.durable_root.as_deref().ok_or_else(|| {
3390                MongrelError::Other("database root descriptor was not pinned".into())
3391            })?,
3392            wal_dek.as_ref(),
3393        )?;
3394        recover_ddl_from_records(
3395            &root,
3396            Some(lock.durable_root.as_deref().ok_or_else(|| {
3397                MongrelError::Other("database root descriptor was not pinned".into())
3398            })?),
3399            &mut cat,
3400            meta_dek.as_ref(),
3401            false,
3402            None,
3403            &recovery_records,
3404        )?;
3405
3406        // Fail early if the database is not in require_auth mode — the caller
3407        // picked the wrong constructor.
3408        if !cat.require_auth {
3409            return Err(MongrelError::AuthNotRequired);
3410        }
3411
3412        // Verify credentials against the on-disk catalog before constructing
3413        // the full Database handle. This reads users/hashes directly from the
3414        // loaded catalog rather than going through the Database::verify_user
3415        // method (which requires a constructed Database).
3416        let user = cat
3417            .users
3418            .iter()
3419            .find(|u| u.username == username)
3420            .filter(|u| !u.password_hash.is_empty())
3421            .ok_or_else(|| MongrelError::InvalidCredentials {
3422                username: username.to_string(),
3423            })?;
3424        let password_ok = crate::auth::verify_password(password, &user.password_hash)
3425            .map_err(MongrelError::Other)?;
3426        if !password_ok {
3427            return Err(MongrelError::InvalidCredentials {
3428                username: username.to_string(),
3429            });
3430        }
3431
3432        // Resolve the principal from the catalog (roles + permissions).
3433        let principal =
3434            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
3435                MongrelError::InvalidCredentials {
3436                    username: username.to_string(),
3437                }
3438            })?;
3439
3440        Self::finish_open(
3441            root,
3442            cat,
3443            kek,
3444            meta_dek,
3445            true,
3446            Some(recovery_checkpoint),
3447            Some(recovery_records),
3448            Some(principal),
3449            lock,
3450            resources,
3451            mode_gate,
3452        )
3453    }
3454
3455    /// Create a fresh plaintext database with `require_auth = true` and a
3456    /// single admin user. The returned handle is already authenticated as
3457    /// that admin — every subsequent operation is checked against the admin
3458    /// principal (which bypasses all permission checks via `is_admin`).
3459    ///
3460    /// This is the bootstrap path: there is no window where the database
3461    /// requires auth but has no users.
3462    ///
3463    /// See `docs/15-credential-enforcement.md`.
3464    pub fn create_with_credentials(
3465        root: impl AsRef<Path>,
3466        admin_username: &str,
3467        admin_password: &str,
3468    ) -> Result<Self> {
3469        let (root, lock) = Self::begin_create(root)?;
3470        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
3471    }
3472
3473    /// Create a fresh encrypted database with `require_auth = true` and a
3474    /// single admin user. Composes encryption-at-rest with credential
3475    /// enforcement.
3476    pub fn create_encrypted_with_credentials(
3477        root: impl AsRef<Path>,
3478        passphrase: &str,
3479        admin_username: &str,
3480        admin_password: &str,
3481    ) -> Result<Self> {
3482        let (root, lock) = Self::begin_create(root)?;
3483        let salt = crate::encryption::random_salt()?;
3484        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
3485        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3486        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3487    }
3488
3489    /// Create a KMS-encrypted database with an authenticated admin handle.
3490    pub fn create_with_kms_and_credentials(
3491        root: impl AsRef<Path>,
3492        provider: &dyn crate::security_hardening::KeyManagementProvider,
3493        kms_key_id: &str,
3494        admin_username: &str,
3495        admin_password: &str,
3496    ) -> Result<Self> {
3497        let (root, lock) = Self::begin_create(root)?;
3498        let kek = create_kms_kek(&root, provider, kms_key_id)?;
3499        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3500    }
3501
3502    fn create_inner_with_credentials(
3503        root: PathBuf,
3504        kek: Option<Arc<crate::encryption::Kek>>,
3505        admin_username: &str,
3506        admin_password: &str,
3507        lock: ExclusiveDatabaseLease,
3508    ) -> Result<Self> {
3509        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
3510        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3511
3512        // Build the initial catalog with require_auth = true and one admin user.
3513        let password_hash =
3514            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
3515        let scram_sha_256 =
3516            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
3517        let mysql_caching_sha2 =
3518            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
3519        let mut cat = Catalog::empty();
3520        cat.require_auth = true;
3521        cat.next_user_id = 2;
3522        cat.users.push(crate::auth::UserEntry {
3523            id: 1,
3524            username: admin_username.to_string(),
3525            password_hash,
3526            scram_sha_256: Some(scram_sha_256),
3527            mysql_caching_sha2: Some(mysql_caching_sha2),
3528            roles: Vec::new(),
3529            is_admin: true,
3530            created_epoch: 0,
3531        });
3532        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3533
3534        // The handle is constructed already authenticated as the admin user
3535        // it just created — no separate verify step needed.
3536        let admin_principal = crate::auth::Principal {
3537            user_id: 1,
3538            created_epoch: 0,
3539            username: admin_username.to_string(),
3540            is_admin: true,
3541            roles: Vec::new(),
3542            permissions: Vec::new(),
3543        };
3544        Self::finish_open(
3545            root,
3546            cat,
3547            kek,
3548            meta_dek,
3549            false,
3550            None,
3551            None,
3552            Some(admin_principal),
3553            lock,
3554            CoreResourceConfig::default(),
3555            OpenModeGate::Create(crate::storage_mode::StorageMode::Standalone),
3556        )
3557    }
3558
3559    fn reject_existing_database(root: &Path) -> Result<()> {
3560        // Refuse to overwrite an existing database. If CATALOG exists, the
3561        // directory already contains a real database; replacing it destroys data.
3562        if root.join(catalog::CATALOG_FILENAME).exists() {
3563            return Err(MongrelError::InvalidArgument(format!(
3564                "database already exists at {}; use Database::open() to open it, \
3565                 or remove the directory first",
3566                root.display()
3567            )));
3568        }
3569        Ok(())
3570    }
3571
3572    fn open_inner(
3573        root: impl AsRef<Path>,
3574        kek: Option<Arc<crate::encryption::Kek>>,
3575        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3576    ) -> Result<Self> {
3577        Self::open_inner_with_lock_timeout(
3578            root,
3579            kek,
3580            None,
3581            0,
3582            CoreResourceConfig::default(),
3583            OpenModeGate::Normal,
3584        )
3585    }
3586
3587    /// Open an existing plaintext database through the special
3588    /// offline-validation API (spec section 5.3): any storage mode, read-only.
3589    pub(crate) fn open_offline_validation(root: impl AsRef<Path>) -> Result<Self> {
3590        Self::open_inner_with_lock_timeout(
3591            root,
3592            None,
3593            None,
3594            0,
3595            CoreResourceConfig::default(),
3596            OpenModeGate::OfflineValidation,
3597        )
3598    }
3599
3600    /// Internal recovery open for a staging directory explicitly marked as a
3601    /// read-only replica. It bypasses user authentication only so PITR can
3602    /// replay auth-mode and password transitions; it is not public API.
3603    pub(crate) fn open_replica_recovery_durable(
3604        root: &crate::durable_file::DurableRoot,
3605    ) -> Result<Self> {
3606        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3607        Self::open_replica_recovery_inner(root, None, lock)
3608    }
3609
3610    pub(crate) fn open_encrypted_replica_recovery_durable(
3611        root: &crate::durable_file::DurableRoot,
3612        passphrase: &str,
3613    ) -> Result<Self> {
3614        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3615        let salt = read_encryption_salt(root)?;
3616        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3617        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
3618    }
3619
3620    fn open_replica_recovery_inner(
3621        root: PathBuf,
3622        kek: Option<Arc<crate::encryption::Kek>>,
3623        lock: ExclusiveDatabaseLease,
3624    ) -> Result<Self> {
3625        if !root.join(META_DIR).join("replica").is_file() {
3626            return Err(MongrelError::InvalidArgument(
3627                "recovery auth bypass requires a marked replica staging directory".into(),
3628            ));
3629        }
3630        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3631        let mut cat = catalog::read_durable(
3632            lock.durable_root.as_deref().ok_or_else(|| {
3633                MongrelError::Other("database root descriptor was not pinned".into())
3634            })?,
3635            meta_dek.as_ref(),
3636        )?
3637        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3638        let recovery_checkpoint = cat.clone();
3639        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3640        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3641            lock.durable_root.as_deref().ok_or_else(|| {
3642                MongrelError::Other("database root descriptor was not pinned".into())
3643            })?,
3644            wal_dek.as_ref(),
3645        )?;
3646        recover_ddl_from_records(
3647            &root,
3648            Some(lock.durable_root.as_deref().ok_or_else(|| {
3649                MongrelError::Other("database root descriptor was not pinned".into())
3650            })?),
3651            &mut cat,
3652            meta_dek.as_ref(),
3653            false,
3654            None,
3655            &recovery_records,
3656        )?;
3657        let principal = if cat.require_auth {
3658            cat.users
3659                .iter()
3660                .find(|user| user.is_admin)
3661                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
3662                .ok_or_else(|| {
3663                    MongrelError::Schema(
3664                        "authenticated replica catalog has no recoverable admin".into(),
3665                    )
3666                })?
3667                .into()
3668        } else {
3669            None
3670        };
3671        Self::finish_open(
3672            root,
3673            cat,
3674            kek,
3675            meta_dek,
3676            true,
3677            Some(recovery_checkpoint),
3678            Some(recovery_records),
3679            principal,
3680            lock,
3681            CoreResourceConfig::default(),
3682            OpenModeGate::Normal,
3683        )
3684    }
3685
3686    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
3687    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
3688    ///
3689    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
3690    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
3691    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
3692    ///
3693    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
3694    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
3695    /// caller never blocks past its budget even at the tail of a busy lock
3696    /// holder's lock-window.
3697    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
3698        use fs2::FileExt;
3699        if timeout_ms == 0 {
3700            return f.try_lock_exclusive();
3701        }
3702        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
3703        let deadline =
3704            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
3705        let mut next_sleep = std::time::Duration::from_millis(1);
3706        let mut recorded_wait = false;
3707        loop {
3708            match f.try_lock_exclusive() {
3709                Ok(()) => return Ok(()),
3710                Err(e) if Self::is_file_lock_contended(&e) => {
3711                    if !recorded_wait {
3712                        DATABASE_OPEN_WAIT_COUNT.fetch_add(1, Ordering::Relaxed);
3713                        recorded_wait = true;
3714                    }
3715                    let now = std::time::Instant::now();
3716                    if now >= deadline {
3717                        return Err(std::io::Error::new(
3718                            std::io::ErrorKind::WouldBlock,
3719                            format!("could not acquire database lock within {timeout_ms}ms"),
3720                        ));
3721                    }
3722                    let remaining = deadline - now;
3723                    let sleep = next_sleep.min(remaining);
3724                    std::thread::sleep(sleep);
3725                    // Cap the per-iteration sleep so a single back-off step
3726                    // never overshoots the remaining budget.
3727                    next_sleep = next_sleep
3728                        .saturating_mul(10)
3729                        .min(std::time::Duration::from_millis(50));
3730                }
3731                Err(e) => return Err(e),
3732            }
3733        }
3734    }
3735
3736    fn is_file_lock_contended(error: &std::io::Error) -> bool {
3737        if error.kind() == std::io::ErrorKind::WouldBlock {
3738            return true;
3739        }
3740        #[cfg(windows)]
3741        {
3742            // LockFileEx reports ERROR_LOCK_VIOLATION without mapping it to
3743            // ErrorKind::WouldBlock.
3744            return error.raw_os_error() == Some(33);
3745        }
3746        #[cfg(not(windows))]
3747        false
3748    }
3749
3750    #[allow(clippy::too_many_arguments)]
3751    fn finish_open(
3752        root: PathBuf,
3753        cat: Catalog,
3754        kek: Option<Arc<crate::encryption::Kek>>,
3755        meta_dek: Option<[u8; META_DEK_LEN]>,
3756        existing: bool,
3757        recovery_checkpoint: Option<Catalog>,
3758        recovery_records: Option<Vec<crate::wal::Record>>,
3759        principal: Option<crate::auth::Principal>,
3760        lock: ExclusiveDatabaseLease,
3761        resources: CoreResourceConfig,
3762        mode_gate: OpenModeGate,
3763    ) -> Result<Self> {
3764        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
3765            MongrelError::Other("database root descriptor was not pinned".into())
3766        })?);
3767        // Storage-mode gate (spec section 5.3, Stage 2E): read the durable
3768        // marker before any recovery side effect and fail closed on corrupt
3769        // or forbidden modes. Legacy databases have no marker and are treated
3770        // as Standalone (backfilled below, after recovery succeeds).
3771        let storage_mode = if existing {
3772            crate::storage_mode::read(&durable_root)?
3773        } else {
3774            None
3775        };
3776        match (&mode_gate, &storage_mode) {
3777            (OpenModeGate::Create(_), _) => {}
3778            (OpenModeGate::Normal | OpenModeGate::SharedCore, mode) => {
3779                crate::storage_mode::check_open(mode.as_ref(), false)?
3780            }
3781            (OpenModeGate::OfflineValidation, mode) => {
3782                crate::storage_mode::check_open(mode.as_ref(), true)?
3783            }
3784            (
3785                OpenModeGate::ClusterRuntime {
3786                    cluster_id,
3787                    node_id,
3788                    database_id,
3789                },
3790                Some(actual),
3791            ) => {
3792                let expected = crate::storage_mode::StorageMode::ClusterReplica {
3793                    cluster_id: *cluster_id,
3794                    node_id: *node_id,
3795                    database_id: *database_id,
3796                };
3797                if *actual != expected {
3798                    return Err(
3799                        crate::storage_mode::StorageModeError::IdentityMismatch(format!(
3800                            "expected {expected:?}, marker holds {actual:?}"
3801                        ))
3802                        .into(),
3803                    );
3804                }
3805            }
3806            (OpenModeGate::ClusterRuntime { .. }, None) => {
3807                return Err(crate::storage_mode::StorageModeError::IdentityMismatch(
3808                    "expected a ClusterReplica marker; directory has none".into(),
3809                )
3810                .into());
3811            }
3812        }
3813        let read_only = match &mode_gate {
3814            OpenModeGate::OfflineValidation | OpenModeGate::ClusterRuntime { .. } => true,
3815            // A freshly created cluster replica rejects user writes from the
3816            // start: mutations arrive through the replicated apply path only.
3817            OpenModeGate::Create(mode) => mode.cluster_identity().is_some(),
3818            OpenModeGate::Normal | OpenModeGate::SharedCore => false,
3819        } || if existing {
3820            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
3821                Ok(_) => true,
3822                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
3823                Err(error) => return Err(error.into()),
3824            }
3825        } else {
3826            false
3827        };
3828        let recovered_catalog = cat;
3829        let mut cat = recovered_catalog.clone();
3830        let abandoned = if existing && !read_only {
3831            let abandoned = cat
3832                .tables
3833                .iter()
3834                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
3835                .map(|entry| entry.table_id)
3836                .collect::<Vec<_>>();
3837            for entry in &mut cat.tables {
3838                if abandoned.contains(&entry.table_id) {
3839                    entry.state = TableState::Dropped {
3840                        at_epoch: cat.db_epoch,
3841                    };
3842                }
3843            }
3844            abandoned
3845        } else {
3846            Vec::new()
3847        };
3848        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3849        let recovery_records = match (existing, recovery_records) {
3850            (true, Some(records)) => records,
3851            (true, None) => {
3852                return Err(MongrelError::Other(
3853                    "existing open has no validated WAL recovery plan".into(),
3854                ))
3855            }
3856            (false, _) => Vec::new(),
3857        };
3858        let (history_epochs, history_start) =
3859            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
3860        let open_generation = if existing {
3861            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
3862                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3863            })?;
3864            let recovered_table_ids = cat
3865                .tables
3866                .iter()
3867                .filter(|entry| {
3868                    checkpoint
3869                        .tables
3870                        .iter()
3871                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
3872                })
3873                .map(|entry| entry.table_id)
3874                .collect::<HashSet<_>>();
3875            let reconciled_table_ids = cat
3876                .tables
3877                .iter()
3878                .filter(|entry| {
3879                    checkpoint
3880                        .tables
3881                        .iter()
3882                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
3883                        .is_some_and(|checkpoint| {
3884                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
3885                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
3886                        })
3887                })
3888                .map(|entry| entry.table_id)
3889                .collect::<HashSet<_>>();
3890            validate_shared_wal_recovery_plan(
3891                &durable_root,
3892                &cat,
3893                &recovered_table_ids,
3894                &reconciled_table_ids,
3895                meta_dek.as_ref(),
3896                kek.clone(),
3897                &recovery_records,
3898            )?;
3899            let retained_generation = recovery_records
3900                .iter()
3901                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
3902                .map(|record| record.txn_id >> 32)
3903                .max()
3904                .unwrap_or(0);
3905            let head_generation =
3906                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
3907            let durable_floor = match head_generation {
3908                Some(head) if retained_generation > head => {
3909                    return Err(MongrelError::CorruptWal {
3910                        offset: retained_generation,
3911                        reason: format!(
3912                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
3913                        ),
3914                    })
3915                }
3916                Some(head) => head,
3917                None => retained_generation,
3918            };
3919            let stored = catalog::read_generation(&durable_root)?;
3920            if stored.is_some_and(|generation| generation < durable_floor) {
3921                return Err(MongrelError::Other(format!(
3922                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
3923                )));
3924            }
3925            let bumped = stored
3926                .unwrap_or(durable_floor)
3927                .max(durable_floor)
3928                .checked_add(1)
3929                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
3930            if bumped > u32::MAX as u64 {
3931                return Err(MongrelError::Full(
3932                    "open-generation namespace exhausted".into(),
3933                ));
3934            }
3935            bumped
3936        } else {
3937            0
3938        };
3939        let principal = if cat.require_auth && !matches!(&mode_gate, OpenModeGate::SharedCore) {
3940            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3941            Some(
3942                Self::resolve_bound_principal_from_catalog(&cat, supplied)
3943                    .ok_or(MongrelError::AuthRequired)?,
3944            )
3945        } else {
3946            principal
3947        };
3948        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
3949        if existing {
3950            for entry in &cat.tables {
3951                if !matches!(entry.state, TableState::Live) {
3952                    continue;
3953                }
3954                match durable_root
3955                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
3956                {
3957                    Ok(root) => {
3958                        table_roots.insert(entry.table_id, Arc::new(root));
3959                    }
3960                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3961                    Err(error) => return Err(error.into()),
3962                }
3963            }
3964        }
3965
3966        // No database-tree mutation occurs above this point. DDL, row payloads,
3967        // immutable runs, auth state, retention, and generation state have all
3968        // been validated against the authoritative recovered catalog.
3969        if existing {
3970            let mut applied = recovery_checkpoint.ok_or_else(|| {
3971                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3972            })?;
3973            recover_ddl_from_records(
3974                &root,
3975                Some(&durable_root),
3976                &mut applied,
3977                meta_dek.as_ref(),
3978                true,
3979                Some(&table_roots),
3980                &recovery_records,
3981            )?;
3982            let catalog_value = |catalog: &Catalog| {
3983                serde_json::to_value(catalog)
3984                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
3985            };
3986            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
3987                return Err(MongrelError::CorruptWal {
3988                    offset: 0,
3989                    reason: "validated and applied DDL recovery plans differ".into(),
3990                });
3991            }
3992            if catalog_value(&cat)? != catalog_value(&applied)? {
3993                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3994            }
3995            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
3996            if !read_only {
3997                sweep_unreferenced_table_dirs(&root, &cat)?;
3998            }
3999            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
4000                Ok(()) => {}
4001                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4002                Err(error) => return Err(error.into()),
4003            }
4004        }
4005
4006        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
4007        let snapshots = Arc::new(SnapshotRegistry::new());
4008        snapshots.configure_history(history_epochs, history_start);
4009        // S1E-003: exactly one node-level memory governor per storage core.
4010        // Both caches reserve under it (their live bytes always show up in
4011        // the governor's accounting) and register as reclaimable subsystems
4012        // the governor drives under escalation step 2.
4013        let memory_governor = crate::memory::MemoryGovernor::new(
4014            crate::memory::GovernorConfig::new(resources.memory_budget_bytes),
4015        )
4016        .map_err(|error| MongrelError::InvalidArgument(format!("memory governor: {error}")))?;
4017        // S1E-004: exactly one spill manager per core, rooted at
4018        // `<db-root>/temp/spill`; opening it runs the startup sweep that
4019        // deletes every stale spill file a prior process run left behind.
4020        let spill_manager = crate::spill::SpillManager::open(
4021            &durable_root,
4022            crate::spill::SpillConfig::new(resources.temp_disk_budget_bytes),
4023            meta_dek,
4024        )?;
4025        // S1F-002: exactly one durable job registry per core (sibling `JOBS`
4026        // file). Crash recovery inside `open` parks mid-`Running` jobs as
4027        // `Paused` for an operator-driven resume.
4028        let job_registry = Arc::new(crate::jobs::JobRegistry::open(&root, meta_dek.as_ref())?);
4029        let page_cache = Arc::new(crate::cache::Sharded::new(
4030            crate::cache::CACHE_SHARDS,
4031            || {
4032                crate::cache::PageCache::new(
4033                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
4034                )
4035                .with_governor(
4036                    memory_governor.clone(),
4037                    crate::memory::MemoryClass::PageCache,
4038                )
4039            },
4040        ));
4041        memory_governor.register_reclaimable(&page_cache);
4042        let decoded_cache = Arc::new(crate::cache::Sharded::new(
4043            crate::cache::CACHE_SHARDS,
4044            || {
4045                crate::cache::DecodedPageCache::new(
4046                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
4047                )
4048                .with_governor(
4049                    memory_governor.clone(),
4050                    crate::memory::MemoryClass::DecodedCache,
4051                )
4052            },
4053        ));
4054        memory_governor.register_reclaimable(&decoded_cache);
4055        let commit_lock = Arc::new(Mutex::new(()));
4056        let shared_wal = Arc::new(Mutex::new(if existing {
4057            crate::wal::SharedWal::open_durable_root_validated(
4058                Arc::clone(&durable_root),
4059                Epoch(cat.db_epoch),
4060                wal_dek.clone(),
4061                Some(&recovery_records),
4062            )?
4063        } else {
4064            crate::wal::SharedWal::create_with_durable_root(
4065                Arc::clone(&durable_root),
4066                Epoch(cat.db_epoch),
4067                wal_dek.clone(),
4068            )?
4069        }));
4070        // Shared write-path state handed to every mounted table so single-table
4071        // `put`/`commit` writes route through the one shared WAL, the one group-
4072        // commit coordinator, and the one poison flag (B1).
4073        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
4074        // S1A-004: the lifecycle is created before the write-path plumbing so
4075        // the group-commit coordinator and every mounted table can poison it on
4076        // an unrecoverable fsync error; `mark_open` happens only once every
4077        // initialization step below has completed.
4078        let lifecycle = Arc::new(crate::core::LifecycleController::new());
4079        let group = Arc::new(
4080            crate::txn::GroupCommit::new(shared_wal.lock().durable_seq())
4081                .with_lifecycle(Arc::clone(&lifecycle)),
4082        );
4083        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
4084        // Final base value is set after the open-generation bump below; tables
4085        // only draw ids once the user issues a write (post-open), so the
4086        // placeholder is never observed.
4087        let txn_ids = Arc::new(Mutex::new(1u64));
4088        let _ = abandoned;
4089        // FND-004 (spec §9.4): the commit-log authority for this database. The
4090        // transaction-id allocator Arc is stable; its base value is re-seeded
4091        // below once the open generation is final. The HLC clock is the node's
4092        // single timestamp authority (spec §4.1, §8.2; ADR-0003): standalone
4093        // mode uses node tiebreaker 0, and the skew bound only engages once
4094        // remote timestamps are observed (Stage 2 replication).
4095        let hlc = Arc::new(mongreldb_types::hlc::HlcClock::new(
4096            0,
4097            std::time::Duration::from_millis(500),
4098        ));
4099        // S1B-005: durable idempotency ledger (sibling `TXN_IDEMPOTENCY` file
4100        // via `DurableRoot::write_atomic`, mirroring `jobs.rs`'s `JOBS`).
4101        let idempotency = crate::txn::IdempotencyLedger::open(Arc::clone(&durable_root), meta_dek)?;
4102        let standalone_commit_log = Arc::new(crate::commit_log::StandaloneCommitLog::new(
4103            Arc::clone(&shared_wal),
4104            Arc::clone(&group),
4105            Arc::clone(&epoch),
4106            Arc::clone(&commit_lock),
4107            Arc::clone(&txn_ids),
4108            root.clone(),
4109            wal_dek.clone(),
4110            Arc::clone(&hlc),
4111        ));
4112        let commit_log: Arc<dyn mongreldb_log::CommitLog> = standalone_commit_log.clone();
4113
4114        // Build the shared auth state early — it's cloned into every mounted
4115        // Table's SharedCtx so the Table layer can enforce permissions without
4116        // a reference back to Database. The `require_auth` flag is mirrored
4117        // from the catalog; `enable_auth` / `refresh_principal` update it live.
4118        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
4119        let security_coordinator = security_coordinator(&root, cat.security_version);
4120        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> =
4121            if matches!(&mode_gate, OpenModeGate::SharedCore) {
4122                // Shared tables are reachable only through DatabaseHandle's
4123                // principal-explicit boundary. A core-wide checker cannot
4124                // represent multiple simultaneous principals.
4125                None
4126            } else {
4127                Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
4128                    auth_state.clone(),
4129                )))
4130            };
4131
4132        // Open every live table against the shared context. Mounted tables have
4133        // no private WAL (B1) — `open_in` just loads the manifest/runs and
4134        // advances the shared epoch authority to its manifest epoch, so the
4135        // final shared watermark is the max across all tables. All of a mounted
4136        // table's committed records are replayed below from the shared WAL.
4137        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
4138        for entry in &cat.tables {
4139            if !matches!(entry.state, TableState::Live) {
4140                continue;
4141            }
4142            let table_root = match table_roots.remove(&entry.table_id) {
4143                Some(root) => root,
4144                None => Arc::new(
4145                    durable_root
4146                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
4147                ),
4148            };
4149            let tdir = table_root.io_path()?;
4150            let ctx = SharedCtx {
4151                root_guard: Some(table_root),
4152                epoch: Arc::clone(&epoch),
4153                page_cache: Arc::clone(&page_cache),
4154                decoded_cache: Arc::clone(&decoded_cache),
4155                snapshots: Arc::clone(&snapshots),
4156                kek: kek.clone(),
4157                commit_lock: Arc::clone(&commit_lock),
4158                shared: Some(crate::engine::SharedWalCtx {
4159                    wal: Arc::clone(&shared_wal),
4160                    group: Arc::clone(&group),
4161                    poisoned: Arc::clone(&poisoned),
4162                    txn_ids: Arc::clone(&txn_ids),
4163                    change_wake: change_wake.clone(),
4164                    lifecycle: Arc::clone(&lifecycle),
4165                    hlc: Arc::clone(&hlc),
4166                }),
4167                table_name: Some(entry.name.clone()),
4168                auth: auth_checker.clone(),
4169                read_only,
4170            };
4171            let t = Table::open_in(&tdir, ctx)?;
4172            tables.insert(entry.table_id, TableHandle::new(t));
4173        }
4174
4175        // Recover transaction writes from the shared WAL (spec §15). This is the
4176        // single durability source for mounted tables: it applies every committed
4177        // record — both single-table `Table::commit` writes and cross-table
4178        // transactions — gated by each table's `flushed_epoch` (records already
4179        // durable in a run are not re-applied).
4180        if existing {
4181            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
4182            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
4183            if read_only {
4184                crate::replication::reconcile_replica_epoch_durable(
4185                    &durable_root,
4186                    epoch.visible().0,
4187                )?;
4188            }
4189            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
4190            // large transactions (spec §8.5, review fix #14).
4191            sweep_pending_txn_dirs(&root, &cat);
4192        }
4193
4194        // Persist only after all semantic recovery and table mounting succeeds.
4195        catalog::write_generation(&durable_root, open_generation)?;
4196        // Storage-mode marker (spec section 5.3): fresh creates record their
4197        // mode; legacy databases (no marker file) are backfilled as Standalone
4198        // on first open. Like the open-generation write above, this is durable
4199        // open bookkeeping, so it also runs for read-only opens.
4200        match &mode_gate {
4201            OpenModeGate::Create(mode) => crate::storage_mode::write(&durable_root, mode)?,
4202            _ if existing && storage_mode.is_none() => {
4203                crate::storage_mode::write(
4204                    &durable_root,
4205                    &crate::storage_mode::StorageMode::Standalone,
4206                )?;
4207            }
4208            _ => {}
4209        }
4210        shared_wal.lock().seal_open_generation(open_generation)?;
4211        crate::replication::replication_identity_durable(&durable_root)?;
4212        let next_txn_id = (open_generation << 32) | 1;
4213        // Seed the shared txn-id allocator now that the generation is final.
4214        *txn_ids.lock() = next_txn_id;
4215        let mut lock = lock;
4216        lock.mark_open()?;
4217
4218        // Initialization is complete: recovery, WAL opening, open-generation
4219        // advancement, and table mounting all happened above, exactly once for
4220        // this core (spec §10.1, S1A-002). The core starts life `Open`.
4221        lifecycle.mark_open();
4222        let core = DatabaseCore {
4223            root,
4224            durable_root,
4225            lifecycle,
4226            registry: std::sync::OnceLock::new(),
4227            read_only,
4228            catalog: RwLock::new(cat),
4229            security_coordinator,
4230            security_catalog_disk_reads: AtomicU64::new(0),
4231            rls_cache: Mutex::new(RlsCache::default()),
4232            epoch,
4233            snapshots,
4234            memory_governor,
4235            page_cache,
4236            decoded_cache,
4237            spill_manager,
4238            resource_groups: crate::resource::ResourceGroupRegistry::with_defaults(),
4239            embedding_providers: crate::embedding::EmbeddingProviderRegistry::new(),
4240            service_principals: crate::service_principal::ServicePrincipalStore::new(),
4241            job_registry,
4242            commit_lock,
4243            kms_rotation_lock: Mutex::new(()),
4244            shared_wal,
4245            next_txn_id: txn_ids,
4246            tables: RwLock::new(tables),
4247            kek,
4248            ddl_lock: Mutex::new(()),
4249            meta_dek,
4250            conflicts: crate::txn::ConflictIndex::new(),
4251            active_txns: crate::txn::ActiveTxns::new(),
4252            lock_manager: Arc::new(crate::locks::LockManager::new()),
4253            poisoned,
4254            group,
4255            commit_log: RwLock::new(commit_log),
4256            standalone_commit_log,
4257            hlc,
4258            idempotency,
4259            commit_ts_ledger: Mutex::new(commit_ts_ledger_from_recovery(&recovery_records)),
4260            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
4261            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
4262            replication_barrier: parking_lot::RwLock::new(()),
4263            replication_wal_retention_segments: AtomicUsize::new(0),
4264            backup_pins: Arc::new(Mutex::new(HashMap::new())),
4265            spill_hook: Mutex::new(None),
4266            security_commit_hook: Mutex::new(None),
4267            catalog_commit_hook: Mutex::new(None),
4268            backup_hook: Mutex::new(None),
4269            fk_lock_hook: Mutex::new(None),
4270            replication_hook: Mutex::new(None),
4271            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
4272            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
4273            trigger_max_loop_iterations: AtomicU32::new(
4274                TriggerConfig::default().max_loop_iterations,
4275            ),
4276            notify: {
4277                let (tx, _rx) = tokio::sync::broadcast::channel(256);
4278                tx
4279            },
4280            change_wake,
4281            _lock: Mutex::new(Some(lock)),
4282        };
4283        Ok(Self {
4284            core: Arc::new(core),
4285            principal: RwLock::new(principal),
4286            auth_state,
4287            shared: false,
4288        })
4289    }
4290
4291    /// The current reader-visible epoch.
4292    pub fn visible_epoch(&self) -> Epoch {
4293        self.epoch.visible()
4294    }
4295
4296    /// The catalog's monotonic command version (S1F-001).
4297    pub fn catalog_version(&self) -> u64 {
4298        self.catalog.read().catalog_version
4299    }
4300
4301    /// The durable storage mode recorded for this database (spec section 5.3).
4302    /// `None` only while a legacy pre-marker database is mid-open (the marker
4303    /// is backfilled before open completes).
4304    pub fn storage_mode(&self) -> Result<Option<crate::storage_mode::StorageMode>> {
4305        Ok(crate::storage_mode::read(&self.durable_root)?)
4306    }
4307
4308    /// The core's node-level memory governor (S1E-003). Exactly one per core;
4309    /// the page caches reserve under it and register as reclaimable.
4310    pub fn memory_governor(&self) -> &crate::memory::MemoryGovernor {
4311        &self.memory_governor
4312    }
4313
4314    /// Workload resource-group registry (S1E-002). Seeded with defaults for
4315    /// every [`crate::resource::WorkloadClass`] at open.
4316    pub fn resource_groups(&self) -> &crate::resource::ResourceGroupRegistry {
4317        &self.resource_groups
4318    }
4319
4320    /// Process-local embedding provider registry. Core storage never hard-codes
4321    /// an external vendor; register local/remote providers here when generation
4322    /// is desired. Application-supplied vectors need no registration.
4323    pub fn embedding_providers(&self) -> &crate::embedding::EmbeddingProviderRegistry {
4324        &self.embedding_providers
4325    }
4326
4327    /// Remove a provider only when no live schema references it.
4328    pub fn unregister_embedding_provider(&self, provider_id: &str) -> Result<bool> {
4329        let references = self
4330            .catalog
4331            .read()
4332            .tables
4333            .iter()
4334            .flat_map(|table| &table.schema.columns)
4335            .filter(|column| {
4336                column
4337                    .embedding_source
4338                    .as_ref()
4339                    .and_then(crate::embedding::EmbeddingSource::provider_id)
4340                    == Some(provider_id)
4341            })
4342            .count();
4343        self.embedding_providers
4344            .unregister_unreferenced(provider_id, references)
4345            .map_err(embedding_error)
4346    }
4347
4348    fn materialize_generated_embeddings(
4349        &self,
4350        staging: &mut [(u64, crate::txn::Staged)],
4351        control: Option<&crate::ExecutionControl>,
4352    ) -> Result<()> {
4353        let schemas = {
4354            let catalog = self.catalog.read();
4355            catalog
4356                .tables
4357                .iter()
4358                .map(|table| (table.table_id, table.schema.clone()))
4359                .collect::<HashMap<_, _>>()
4360        };
4361        let fallback_control = crate::ExecutionControl::new(None);
4362        let control = control.unwrap_or(&fallback_control);
4363        for (table_id, staged) in &mut *staging {
4364            let (cells, mut update_changed_columns) = match staged {
4365                crate::txn::Staged::Put(cells) => (cells, None),
4366                crate::txn::Staged::Update {
4367                    new_row,
4368                    changed_columns,
4369                    ..
4370                } => (new_row, Some(changed_columns)),
4371                crate::txn::Staged::Delete(_) | crate::txn::Staged::Truncate => continue,
4372            };
4373            let schema = schemas.get(table_id).ok_or_else(|| {
4374                MongrelError::Schema(format!("table id {table_id} disappeared during embedding"))
4375            })?;
4376            for target in &schema.columns {
4377                let Some(crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec }) =
4378                    target.embedding_source.as_ref()
4379                else {
4380                    continue;
4381                };
4382                let text = render_embedding_input(schema, spec, cells)?;
4383                let reservation_bytes = text
4384                    .len()
4385                    .saturating_add((spec.dimension as usize).saturating_mul(4));
4386                let _reservation = self
4387                    .memory_governor
4388                    .try_reserve(
4389                        reservation_bytes as u64,
4390                        crate::memory::MemoryClass::AiCandidates,
4391                    )
4392                    .map_err(|error| MongrelError::ResourceLimitExceeded {
4393                        resource: "embedding memory",
4394                        requested: reservation_bytes,
4395                        limit: match error {
4396                            crate::memory::MemoryError::Exhausted { available, .. } => {
4397                                available as usize
4398                            }
4399                            _ => 0,
4400                        },
4401                    })?;
4402                let source =
4403                    crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec: spec.clone() };
4404                let (registry_generation, provider) = self
4405                    .embedding_providers
4406                    .resolve_with_generation(&source)
4407                    .map_err(embedding_error)?;
4408                let semantic_identity = provider.semantic_identity();
4409                self.ensure_ann_semantic_identity_compatible(
4410                    *table_id,
4411                    target.id,
4412                    &semantic_identity,
4413                )?;
4414                let vectors = self
4415                    .embedding_providers
4416                    .embed_controlled(
4417                        &source,
4418                        &[text.as_str()],
4419                        spec.dimension,
4420                        control,
4421                        "generated-column-write",
4422                        crate::embedding::EmbeddingLimits::default(),
4423                    )
4424                    .map_err(embedding_error)?;
4425                cells.retain(|(column, _)| *column != target.id);
4426                cells.push((
4427                    target.id,
4428                    crate::memtable::Value::GeneratedEmbedding(Box::new(
4429                        crate::embedding::GeneratedEmbeddingValue {
4430                            vector: vectors.into_iter().next().ok_or_else(|| {
4431                                MongrelError::Other("embedding provider returned no vector".into())
4432                            })?,
4433                            metadata: crate::embedding::GeneratedEmbeddingMetadata {
4434                                provider_id: provider.provider_id().to_owned(),
4435                                model_id: provider.model_id().to_owned(),
4436                                model_version: provider.model_version().to_owned(),
4437                                preprocessing_version: provider.preprocessing_version().to_owned(),
4438                                source_fingerprint: Sha256::digest(text.as_bytes()).into(),
4439                                status: crate::embedding::EmbeddingGenerationStatus::Ready,
4440                                last_error_category: None,
4441                                attempt_count: 1,
4442                                semantic_identity,
4443                                provider_registry_generation: registry_generation,
4444                            },
4445                        },
4446                    )),
4447                ));
4448                if let Some(changed_columns) = update_changed_columns.as_deref_mut() {
4449                    changed_columns.push(target.id);
4450                }
4451            }
4452            if let Some(changed_columns) = update_changed_columns {
4453                changed_columns.sort_unstable();
4454                changed_columns.dedup();
4455            }
4456        }
4457        self.validate_staged_generated_embedding_identities(staging)?;
4458        Ok(())
4459    }
4460
4461    fn ensure_ann_semantic_identity_compatible(
4462        &self,
4463        table_id: u64,
4464        column_id: u16,
4465        identity: &crate::embedding::EmbeddingProviderRef,
4466    ) -> Result<()> {
4467        let tables = self.tables.read();
4468        let Some(handle) = tables.get(&table_id) else {
4469            return Ok(());
4470        };
4471        let table = handle.lock();
4472        let Some(ann) = table.ann_index(column_id) else {
4473            return Ok(());
4474        };
4475        if let Some(existing) = ann.semantic_identity() {
4476            if existing != identity {
4477                return Err(embedding_error(
4478                    crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
4479                        column_id,
4480                        expected: existing.fingerprint_sha256(),
4481                        got: identity.fingerprint_sha256(),
4482                    },
4483                ));
4484            }
4485        }
4486        Ok(())
4487    }
4488
4489    fn validate_staged_generated_embedding_identities(
4490        &self,
4491        staging: &[(u64, crate::txn::Staged)],
4492    ) -> Result<()> {
4493        for (table_id, staged) in staging {
4494            let cells = match staged {
4495                crate::txn::Staged::Put(cells) => cells.as_slice(),
4496                crate::txn::Staged::Update { new_row, .. } => new_row.as_slice(),
4497                crate::txn::Staged::Delete(_) | crate::txn::Staged::Truncate => continue,
4498            };
4499            for (column_id, value) in cells {
4500                let Some(meta) = value.generated_embedding_metadata() else {
4501                    continue;
4502                };
4503                self.ensure_ann_semantic_identity_compatible(
4504                    *table_id,
4505                    *column_id,
4506                    &meta.semantic_identity,
4507                )?;
4508            }
4509        }
4510        Ok(())
4511    }
4512
4513    /// Acquire exclusive row locks for `SELECT ... FOR UPDATE` (spec §10.2).
4514    /// Locks are held until the transaction ends (`release_txn_locks` /
4515    /// commit / abort). Requires an open transaction id from the SQL session.
4516    pub fn lock_rows_for_update(
4517        &self,
4518        txn_id: u64,
4519        table_id: u64,
4520        row_ids: &[crate::rowid::RowId],
4521        control: Option<&crate::ExecutionControl>,
4522    ) -> Result<()> {
4523        for &row_id in row_ids {
4524            self.acquire_txn_lock(
4525                txn_id,
4526                crate::locks::LockKey::row(table_id, row_id),
4527                crate::locks::LockMode::Exclusive,
4528                control,
4529            )?;
4530        }
4531        Ok(())
4532    }
4533
4534    /// The core's node-level spill manager (S1E-004), rooted at
4535    /// `<db-root>/temp/spill`. Query engines open per-query spill sessions
4536    /// from it.
4537    pub fn spill_manager(&self) -> &crate::spill::SpillManager {
4538        &self.spill_manager
4539    }
4540
4541    /// The core's durable job registry (S1F-002, sibling `JOBS` file).
4542    pub fn job_registry(&self) -> &Arc<crate::jobs::JobRegistry> {
4543        &self.job_registry
4544    }
4545
4546    /// S1C-004 diagnostics: every mounted table's active version-retention
4547    /// pin sources, aggregated from [`crate::engine::Table::version_pins_report`].
4548    /// A version may be reclaimed only when it is older than the oldest pin of
4549    /// every source; this report exposes each of them per table.
4550    pub fn version_pins_report(&self) -> Vec<TablePinsReport> {
4551        let names: HashMap<u64, String> = self
4552            .catalog
4553            .read()
4554            .tables
4555            .iter()
4556            .map(|entry| (entry.table_id, entry.name.clone()))
4557            .collect();
4558        let handles: Vec<_> = self
4559            .tables
4560            .read()
4561            .iter()
4562            .map(|(table_id, handle)| (*table_id, handle.clone()))
4563            .collect();
4564        handles
4565            .into_iter()
4566            .map(|(table_id, handle)| {
4567                let pins = handle.lock().version_pins_report();
4568                TablePinsReport {
4569                    table_id,
4570                    table: names.get(&table_id).cloned().unwrap_or_default(),
4571                    pins,
4572                }
4573            })
4574            .collect()
4575    }
4576
4577    /// P0.5-T6: HLC GC floor with named pin sources, projected through the
4578    /// durable commit-ts ledger when an epoch pin has a stamp.
4579    ///
4580    /// Sources without an active pin or without a durable HLC projection
4581    /// report [`HlcTimestamp::ZERO`](mongreldb_types::hlc::HlcTimestamp::ZERO).
4582    /// Physical reclamation still gates on the epoch floor; this is the HLC
4583    /// diagnostic / cutover surface.
4584    pub fn hlc_gc_floor(&self) -> crate::epoch::GcFloor {
4585        let mut combined = crate::epoch::GcFloor::ZERO;
4586        // Database-level transaction / history pins (shared SnapshotRegistry).
4587        if let Some(epoch) = self.snapshots.min_pinned() {
4588            if let Some(ts) = self.commit_ts_for_epoch(epoch) {
4589                combined.transaction_snapshot = ts;
4590            }
4591        }
4592        if let Some(epoch) = self.snapshots.history_floor(self.epoch.visible()) {
4593            if let Some(ts) = self.commit_ts_for_epoch(epoch) {
4594                combined.history_retention = ts;
4595            }
4596        }
4597        // Merge per-table pin registry projections (min of non-zero).
4598        let handles: Vec<_> = self.tables.read().values().cloned().collect();
4599        for handle in handles {
4600            let table_floor = handle
4601                .lock()
4602                .hlc_gc_floor(|epoch| self.commit_ts_for_epoch(epoch));
4603            for (label, ts) in table_floor.sources() {
4604                if ts == mongreldb_types::hlc::HlcTimestamp::ZERO {
4605                    continue;
4606                }
4607                let slot = match label {
4608                    "transaction_snapshot" => &mut combined.transaction_snapshot,
4609                    "history_retention" => &mut combined.history_retention,
4610                    "backup_pitr" => &mut combined.backup_pitr,
4611                    "replication" => &mut combined.replication,
4612                    "read_generation" => &mut combined.read_generation,
4613                    "online_index_build" => &mut combined.online_index_build,
4614                    _ => continue,
4615                };
4616                if *slot == mongreldb_types::hlc::HlcTimestamp::ZERO || ts < *slot {
4617                    *slot = ts;
4618                }
4619            }
4620        }
4621        combined
4622    }
4623
4624    /// The core's key/predicate lock manager (S1B-003).
4625    pub fn lock_manager(&self) -> &Arc<crate::locks::LockManager> {
4626        &self.lock_manager
4627    }
4628
4629    /// S1B-004 step 12: release every lock `txn_id` holds (commit, abort, and
4630    /// rollback all funnel here). Idempotent — releasing a transaction with
4631    /// no holds is a no-op.
4632    /// Release every lock held by `txn_id` (COMMIT/ROLLBACK/FOR UPDATE end).
4633    pub fn release_txn_locks(&self, txn_id: u64) {
4634        self.lock_manager.release_all(txn_id);
4635    }
4636
4637    /// Build a lock request for `txn_id`, inheriting the commit's
4638    /// cancellation control (or a plain one when the commit is uncontrolled).
4639    fn txn_lock_request(
4640        txn_id: u64,
4641        mode: crate::locks::LockMode,
4642        control: Option<&crate::ExecutionControl>,
4643    ) -> crate::locks::LockRequest {
4644        let control = control
4645            .cloned()
4646            .unwrap_or_else(|| crate::ExecutionControl::new(None));
4647        crate::locks::LockRequest::new(txn_id, mode, control)
4648    }
4649
4650    /// Acquire one lock for `txn_id`, bridging the typed [`crate::locks::LockError`]
4651    /// onto the engine error (deadlock victims surface as
4652    /// [`MongrelError::Deadlock`] with [`mongreldb_types::error::ErrorCategory::Deadlock`]).
4653    pub(crate) fn acquire_txn_lock(
4654        &self,
4655        txn_id: u64,
4656        key: crate::locks::LockKey,
4657        mode: crate::locks::LockMode,
4658        control: Option<&crate::ExecutionControl>,
4659    ) -> Result<()> {
4660        self.lock_manager
4661            .acquire(key, Self::txn_lock_request(txn_id, mode, control))
4662            .map_err(MongrelError::from)
4663    }
4664
4665    /// S1B-003: acquire the schema barrier Exclusive for one DDL operation.
4666    /// The hold releases when the returned guard drops (end of the DDL entry
4667    /// point). DML transactions hold the barrier Shared for their whole
4668    /// commit, so schema changes exclude concurrent DML and one another.
4669    fn acquire_schema_barrier_exclusive(&self) -> Result<TxnLockGuard<'_>> {
4670        let txn_id = self.alloc_txn_id()?;
4671        self.acquire_txn_lock(
4672            txn_id,
4673            crate::locks::LockKey::schema_barrier(),
4674            crate::locks::LockMode::Exclusive,
4675            None,
4676        )?;
4677        Ok(TxnLockGuard {
4678            locks: &self.lock_manager,
4679            txn_id,
4680        })
4681    }
4682
4683    /// The commit-log authority for this database (spec §9.4, FND-004). Every
4684    /// commit path proposes through it and visibility publication is gated on
4685    /// its receipts; Stage 2 swaps this standalone adapter for the replicated
4686    /// implementation behind the same `CommitLog` trait.
4687    pub fn commit_log(&self) -> Arc<dyn mongreldb_log::CommitLog> {
4688        Arc::clone(&self.commit_log.read())
4689    }
4690
4691    /// Bind a cluster replica core to its owning consensus commit authority.
4692    ///
4693    /// Standalone roots reject substitution. Cluster replica user mutations
4694    /// remain read-only; this binding exposes the same authoritative log to
4695    /// runtime diagnostics and future proposal surfaces while committed apply
4696    /// stays the only engine mutation path.
4697    pub fn bind_cluster_commit_log(
4698        &self,
4699        commit_log: Arc<dyn mongreldb_log::CommitLog>,
4700    ) -> Result<()> {
4701        match self.storage_mode()? {
4702            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4703                *self.commit_log.write() = commit_log;
4704                Ok(())
4705            }
4706            mode => Err(MongrelError::InvalidArgument(format!(
4707                "commit-log substitution requires ClusterReplica storage, got {mode:?}"
4708            ))),
4709        }
4710    }
4711
4712    /// Break the cluster commit-log binding during replica-runtime teardown.
4713    ///
4714    /// The replacement standalone adapter is retained only as an inert
4715    /// lifecycle anchor. Cluster replica writes remain rejected, and a
4716    /// restarted runtime must bind its new Raft authority before serving.
4717    pub fn detach_cluster_commit_log(&self) -> Result<()> {
4718        match self.storage_mode()? {
4719            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4720                let standalone: Arc<dyn mongreldb_log::CommitLog> =
4721                    self.standalone_commit_log.clone();
4722                *self.commit_log.write() = standalone;
4723                Ok(())
4724            }
4725            mode => Err(MongrelError::InvalidArgument(format!(
4726                "commit-log detachment requires ClusterReplica storage, got {mode:?}"
4727            ))),
4728        }
4729    }
4730
4731    /// The node's HLC timestamp authority (spec §8.2, ADR-0003). Transaction
4732    /// `begin` captures read timestamps here; the commit sequencer allocates
4733    /// commit timestamps from the same clock so commit ts > every participant
4734    /// read/write timestamp of the transaction.
4735    /// Shared HLC clock for this core (P0.5 / cluster apply stamping).
4736    pub fn hlc(&self) -> &mongreldb_types::hlc::HlcClock {
4737        self.hlc_clock()
4738    }
4739
4740    pub(crate) fn hlc_clock(&self) -> &mongreldb_types::hlc::HlcClock {
4741        &self.hlc
4742    }
4743
4744    /// Clone the in-memory catalog (for diagnostics / tests).
4745    pub fn catalog_snapshot(&self) -> Catalog {
4746        self.catalog.read().clone()
4747    }
4748
4749    /// Read SQLite-compatible application metadata persisted in the catalog.
4750    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
4751        let catalog = self.catalog.read();
4752        match key {
4753            "user_version" => Ok(catalog.user_version),
4754            "application_id" => Ok(catalog.application_id),
4755            _ => Err(MongrelError::InvalidArgument(format!(
4756                "unsupported persistent SQL pragma {key:?}"
4757            ))),
4758        }
4759    }
4760
4761    /// Persist SQLite-compatible application metadata and return its exact
4762    /// publication epoch. An unchanged value performs no durable write.
4763    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
4764        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
4765    }
4766
4767    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
4768        &self,
4769        key: &str,
4770        value: i64,
4771        mut before_commit: F,
4772    ) -> Result<Option<Epoch>>
4773    where
4774        F: FnMut() -> Result<()>,
4775    {
4776        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
4777    }
4778
4779    fn set_sql_pragma_i64_with_epoch_inner(
4780        &self,
4781        key: &str,
4782        value: i64,
4783        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
4784    ) -> Result<Option<Epoch>> {
4785        use crate::wal::DdlOp;
4786
4787        self.require(&crate::auth::Permission::Ddl)?;
4788        if self.read_only {
4789            return Err(MongrelError::ReadOnlyReplica);
4790        }
4791        if self.poisoned.load(Ordering::Relaxed) {
4792            return Err(MongrelError::Other(
4793                "database poisoned by fsync error".into(),
4794            ));
4795        }
4796        let _ddl = self.ddl_lock.lock();
4797        let _security_write = self.security_write()?;
4798        self.require(&crate::auth::Permission::Ddl)?;
4799        let mut next_catalog = self.catalog.read().clone();
4800        let target = match key {
4801            "user_version" => &mut next_catalog.user_version,
4802            "application_id" => &mut next_catalog.application_id,
4803            _ => {
4804                return Err(MongrelError::InvalidArgument(format!(
4805                    "unsupported persistent SQL pragma {key:?}"
4806                )))
4807            }
4808        };
4809        if *target == Some(value) {
4810            return Ok(None);
4811        }
4812        *target = Some(value);
4813
4814        let _commit = self.commit_lock.lock();
4815        let epoch = self.epoch.bump_assigned();
4816        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4817        let txn_id = self.alloc_txn_id()?;
4818        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4819        let commit_seq = {
4820            let mut wal = self.shared_wal.lock();
4821            if let Some(before_commit) = before_commit {
4822                before_commit()?;
4823            }
4824            let append: Result<u64> = (|| {
4825                wal.append(
4826                    txn_id,
4827                    WAL_TABLE_ID,
4828                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
4829                        key: key.to_string(),
4830                        value,
4831                    }),
4832                )?;
4833                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4834                wal.append_commit(txn_id, epoch, &[])
4835            })();
4836            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4837        };
4838        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4839        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4840        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
4841        Ok(Some(epoch))
4842    }
4843
4844    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
4845        self.catalog
4846            .read()
4847            .materialized_views
4848            .iter()
4849            .find(|definition| definition.name == name)
4850            .cloned()
4851    }
4852
4853    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
4854        self.catalog.read().materialized_views.clone()
4855    }
4856
4857    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
4858        self.catalog.read().security.clone()
4859    }
4860
4861    pub fn security_active_for(&self, table: &str) -> bool {
4862        self.catalog.read().security.table_has_security(table)
4863    }
4864
4865    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
4866        if self.catalog.read().security_version == expected_version {
4867            return Ok(());
4868        }
4869        self.security_catalog_disk_reads
4870            .fetch_add(1, Ordering::Relaxed);
4871        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
4872            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
4873        let principal = self.principal.read().clone();
4874        let principal = if fresh.require_auth
4875            && principal
4876                .as_ref()
4877                .is_some_and(|principal| principal.user_id != 0)
4878        {
4879            principal
4880                .as_ref()
4881                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
4882        } else {
4883            principal
4884        };
4885        self.auth_state.set_require_auth(fresh.require_auth);
4886        *self.catalog.write() = fresh;
4887        *self.principal.write() = principal.clone();
4888        self.auth_state.set_principal(principal);
4889        Ok(())
4890    }
4891
4892    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
4893        let guard = self.security_coordinator.gate.write();
4894        let version = self.security_coordinator.version.load(Ordering::Acquire);
4895        self.refresh_security_catalog_if_stale(version)?;
4896        Ok(guard)
4897    }
4898
4899    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
4900    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
4901    /// only its restart checkpoint.
4902    /// S1A-004: admit one operation against the core (rejects once the core
4903    /// leaves [`crate::core::LifecycleState::Open`] — draining, closing,
4904    /// closed, or poisoned). The returned guard holds the operation slot
4905    /// until dropped, so `shutdown()` drains exactly the covered operations.
4906    ///
4907    /// Covered set (the mutating/durable paths; the legacy fsync-poison error
4908    /// still wins where a body already checks `self.poisoned`, because the
4909    /// guard is taken after that check):
4910    ///
4911    /// - the cross-table commit funnel
4912    ///   (`commit_transaction_with_external_states_inner`), covering every
4913    ///   user and internal transaction commit;
4914    /// - the catalog publish funnel (`publish_catalog_candidate_with_prelude`),
4915    ///   covering the user/role/grant, trigger, and procedure families;
4916    /// - the inline-WAL DDL bodies: table create/drop/rename/alter, CTAS
4917    ///   rebuilding publish, security-catalog and materialized-view
4918    ///   replacement, external-table create/drop/reset;
4919    /// - storage maintenance: `gc`, `checkpoint`, `compact`, `hot_backup`;
4920    /// - replication bootstrap, batch extraction, and follow-apply.
4921    ///
4922    /// Read-only entry points (snapshots, queries, stats) and the infallible
4923    /// `begin*` constructors are intentionally not covered: reads never block
4924    /// shutdown, and a transaction's durable act is its commit.
4925    pub(crate) fn admit_operation(&self) -> Result<crate::core::OperationGuard> {
4926        self.core.operation_guard()
4927    }
4928
4929    /// S1F-001: wrap `command` in its versioned record and apply it to the
4930    /// catalog candidate (validate → mutate → bump `catalog_version` → append
4931    /// the bounded command history). Security-catalog changes advance the
4932    /// security version inside `CatalogDelta::apply_to`, exactly where the
4933    /// legacy mutation bodies called `advance_security_version` themselves.
4934    fn apply_catalog_command_to(
4935        &self,
4936        next_catalog: &mut Catalog,
4937        command: crate::catalog_cmds::CatalogCommand,
4938    ) -> Result<crate::catalog_cmds::CatalogDelta> {
4939        let record = crate::catalog_cmds::CatalogCommandRecord::next(next_catalog, command);
4940        next_catalog.apply_command(&record)
4941    }
4942
4943    /// Stage 2E (spec section 11.5): apply one committed replicated
4944    /// transaction's staged records through the **same** logic the WAL
4945    /// recovery path uses ([`recover_shared_wal`]).
4946    ///
4947    /// The payload is a complete record sequence of one committed transaction
4948    /// (data ops, `Op::CommitTimestamp`, and exactly one trailing
4949    /// `Op::TxnCommit`), carrying the leader-assigned commit epoch so every
4950    /// replica applies byte-identical records deterministically.
4951    ///
4952    /// Application is durable before it returns: the records are appended
4953    /// verbatim to the core's shared WAL and group-synced, so the raft state
4954    /// machine's post-apply checkpoint never acknowledges rows a crash would
4955    /// lose. Application is idempotent: a payload whose commit epoch is at or
4956    /// below the core's visible watermark is a replay (the state machine
4957    /// dispatches sink-first, checkpoints second — a crash in that window
4958    /// redelivers) and is skipped without side effects. Returns `Ok(true)`
4959    /// when the payload was applied, `Ok(false)` for a recognized replay.
4960    pub fn apply_replicated_records(&self, records: &[crate::wal::Record]) -> Result<bool> {
4961        use crate::wal::Op;
4962        use std::sync::atomic::Ordering;
4963
4964        let _operation = self.admit_operation()?;
4965        if self.poisoned.load(Ordering::Relaxed) {
4966            return Err(MongrelError::Other(
4967                "database poisoned by fsync error".into(),
4968            ));
4969        }
4970        // Structural validation (fail closed): one transaction, exactly one
4971        // commit marker, at the tail.
4972        let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
4973            MongrelError::InvalidArgument("replicated transaction payload is empty".into())
4974        })?;
4975        if records.iter().any(|record| record.txn_id != txn_id) {
4976            return Err(MongrelError::InvalidArgument(
4977                "replicated transaction payload mixes transaction ids".into(),
4978            ));
4979        }
4980        let commits = records
4981            .iter()
4982            .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
4983            .count();
4984        if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
4985            return Err(MongrelError::InvalidArgument(
4986                "replicated transaction payload must end in exactly one commit marker".into(),
4987            ));
4988        }
4989        let commit_epoch = match records.last().map(|r| &r.op) {
4990            Some(Op::TxnCommit { epoch, .. }) => *epoch,
4991            _ => unreachable!("validated above"),
4992        };
4993        // Fail closed on spilled-run linking: an `added_runs` commit links
4994        // run files that exist only on the leader. Leaders never emit such a
4995        // payload — the Stage 2C envelope builder passes every commit through
4996        // [`translate_records_for_replication`], which stages the spilled rows
4997        // as logical `Op::Put` records and strips the run links (spec section
4998        // 11.3 step 3). This check is the defense-in-depth gate behind that
4999        // contract: a payload carrying run references is un-appliable here
5000        // and is rejected deterministically rather than diverging.
5001        if let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) {
5002            if !added_runs.is_empty() {
5003                return Err(MongrelError::InvalidArgument(
5004                    "replicated spilled-run commits are not appliable: the leader must translate \
5005                     the commit through translate_records_for_replication before proposal"
5006                        .into(),
5007                ));
5008            }
5009        }
5010        // Replay guard: the leader assigns one monotonically increasing epoch
5011        // per committed transaction in log order, so anything at or below the
5012        // core's watermark is already applied.
5013        if commit_epoch <= self.epoch.visible().0 {
5014            return Ok(false);
5015        }
5016        // Stage durable first: verbatim WAL append + fsync. The raft log is
5017        // the commit authority; the local WAL is this replica's durable
5018        // staging of already-committed commands (restart recovery replays it
5019        // through the identical path, gated by flushed epochs).
5020        {
5021            let mut wal = self.shared_wal.lock();
5022            for record in records {
5023                wal.append(record.txn_id, 0, record.op.clone())?;
5024            }
5025            if let Err(error) = wal.group_sync() {
5026                self.poisoned.store(true, Ordering::Relaxed);
5027                return Err(error);
5028            }
5029        }
5030        // S1C-004: pin the pre-apply visible epoch for the duration of apply so
5031        // concurrent GC cannot reclaim versions a catch-up reader still needs.
5032        let replication_pins: Vec<crate::retention::PinGuard> = {
5033            let tables = self.tables.read();
5034            let floor = Epoch(self.epoch.visible().0);
5035            tables
5036                .values()
5037                .map(|handle| {
5038                    let t = handle.lock();
5039                    Arc::clone(t.pin_registry())
5040                        .pin(crate::retention::PinSource::Replication, floor)
5041                })
5042                .collect()
5043        };
5044        let tables = self.tables.read().clone();
5045        let catalog = self.catalog.read().clone();
5046        recover_shared_wal(&self.durable_root, &tables, &catalog, &self.epoch, records)?;
5047        // Replica-side commit-ts ledger: record the leader-assigned commit
5048        // epoch's physical time from any CommitTimestamp op, else stamp from
5049        // the local HLC so `commit_ts_for_epoch` serves PITR/read-your-writes
5050        // on replicas (Stage 2 residual).
5051        if let Some(Op::TxnCommit { epoch, .. }) = records.last().map(|r| &r.op) {
5052            let commit_ts = records
5053                .iter()
5054                .rev()
5055                .find_map(|record| match &record.op {
5056                    Op::CommitTimestamp { unix_nanos } => {
5057                        Some(mongreldb_types::hlc::HlcTimestamp {
5058                            physical_micros: unix_nanos / 1_000,
5059                            logical: 0,
5060                            node_tiebreaker: 0,
5061                        })
5062                    }
5063                    _ => None,
5064                })
5065                .unwrap_or_else(|| {
5066                    self.hlc
5067                        .now()
5068                        .unwrap_or(mongreldb_types::hlc::HlcTimestamp {
5069                            physical_micros: 0,
5070                            logical: 0,
5071                            node_tiebreaker: 0,
5072                        })
5073                });
5074            self.record_commit_ts(Epoch(*epoch), commit_ts);
5075        }
5076        drop(replication_pins);
5077        Ok(true)
5078    }
5079
5080    /// Stage 3H engine binding (spec section 12.8): validate staged
5081    /// write-intent payloads at prepare time. A participant that durably
5082    /// prepares these payloads must be able to apply them at resolution, so
5083    /// every check the resolution apply performs runs here first: each
5084    /// payload decodes as a [`StagedTxnWrite`], its table is mounted, and
5085    /// every staged row satisfies the same persisted-row validation the WAL
5086    /// recovery path applies. A malformed payload is rejected deterministically
5087    /// at prepare — never after a decision commits, where a rejection would
5088    /// wedge the replicated apply stream.
5089    pub fn validate_staged_txn_writes(&self, staged: &[Vec<u8>]) -> Result<()> {
5090        let tables = self.tables.read();
5091        for payload in staged {
5092            match StagedTxnWrite::decode(payload)? {
5093                StagedTxnWrite::Put { table_id, rows } => {
5094                    let handle = tables.get(&table_id).ok_or_else(|| {
5095                        MongrelError::InvalidArgument(format!(
5096                            "staged write targets unmounted table {table_id}"
5097                        ))
5098                    })?;
5099                    let rows: Vec<crate::memtable::Row> =
5100                        bincode::deserialize(&rows).map_err(|error| {
5101                            MongrelError::InvalidArgument(format!(
5102                                "staged put payload for table {table_id} cannot decode: {error}"
5103                            ))
5104                        })?;
5105                    let schema = handle.lock().schema().clone();
5106                    for row in &rows {
5107                        validate_recovered_row(&schema, row).map_err(|error| {
5108                            MongrelError::InvalidArgument(format!(
5109                                "staged row for table {table_id} is not appliable: {error}"
5110                            ))
5111                        })?;
5112                    }
5113                }
5114                StagedTxnWrite::Delete { table_id, row_ids } => {
5115                    if !tables.contains_key(&table_id) {
5116                        return Err(MongrelError::InvalidArgument(format!(
5117                            "staged delete targets unmounted table {table_id}"
5118                        )));
5119                    }
5120                    if row_ids.contains(&u64::MAX) {
5121                        return Err(MongrelError::InvalidArgument(format!(
5122                            "staged delete for table {table_id} names an exhausted row id"
5123                        )));
5124                    }
5125                }
5126            }
5127        }
5128        Ok(())
5129    }
5130
5131    /// Stage 3H engine binding (spec section 12.8): apply one committed
5132    /// resolution's staged writes through the replicated apply path
5133    /// ([`Database::apply_replicated_records`]) at the decision's commit
5134    /// timestamp. `txn_tag` is the caller's deterministic per-transaction
5135    /// tag (e.g. a hash of the distributed transaction id); it must be
5136    /// identical on every replica. The synthetic WAL transaction id is
5137    /// derived from it inside the current open-generation namespace with the
5138    /// allocator-avoidance bit set, so resolution records pass the
5139    /// open-generation durability check on reopen and never alias
5140    /// leader-allocated transaction ids.
5141    ///
5142    /// The commit epoch stamped into the synthetic commit marker is one past
5143    /// the core's visible watermark: the replicated apply stream is
5144    /// deterministic, so every replica computes the identical epoch at the
5145    /// identical apply point, and the core's own restart recovery replays the
5146    /// stamped sequence verbatim. Rows are restamped at that epoch by the
5147    /// recovery logic, becoming visible exactly as an ordinary committed
5148    /// transaction's rows.
5149    ///
5150    /// Alias: [`Self::apply_committed_transaction`] — same semantics under the
5151    /// audit's apply-API name (P0.5-T4).
5152    pub fn apply_staged_txn_writes(
5153        &self,
5154        txn_tag: u64,
5155        staged: &[Vec<u8>],
5156        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5157    ) -> Result<bool> {
5158        use crate::wal::Op;
5159
5160        // Decode every payload before any mutation (fail closed).
5161        let mut writes = Vec::with_capacity(staged.len());
5162        for payload in staged {
5163            writes.push(StagedTxnWrite::decode(payload)?);
5164        }
5165        // Generation-namespace the synthetic transaction id: the high 32 bits
5166        // are the current open generation (the reopen path rejects retained
5167        // records from a generation beyond the durable WAL head); the low 32
5168        // bits carry the caller's tag with the top bit set, outside the
5169        // ascending leader-allocated counter space.
5170        let generation = *self.next_txn_id.lock() >> 32;
5171        let txn_id = (generation << 32) | (txn_tag & 0x7FFF_FFFF) | 0x8000_0000;
5172        let mut records = Vec::with_capacity(writes.len() + 2);
5173        for write in writes {
5174            let op = match write {
5175                StagedTxnWrite::Put { table_id, rows } => {
5176                    // `Row::commit_ts` is `#[serde(skip)]` (0.63.1 WAL bincode
5177                    // layout), so the shared decision HLC cannot ride the Put
5178                    // payload. It travels in the trailing `Op::CommitTimestamp`
5179                    // record (physical micros) and is restamped onto every row
5180                    // version by the apply/recovery path, keeping live apply
5181                    // and WAL recovery on the identical stamp.
5182                    let decoded: Vec<crate::memtable::Row> =
5183                        bincode::deserialize(&rows).map_err(|e| {
5184                            MongrelError::Other(format!(
5185                                "staged txn put rows could not be decoded: {e}"
5186                            ))
5187                        })?;
5188                    let stamped = bincode::serialize(&decoded).map_err(|e| {
5189                        MongrelError::Other(format!("staged txn put rows serialize: {e}"))
5190                    })?;
5191                    Op::Put {
5192                        table_id,
5193                        rows: stamped,
5194                    }
5195                }
5196                StagedTxnWrite::Delete { table_id, row_ids } => Op::Delete {
5197                    table_id,
5198                    row_ids: row_ids.into_iter().map(crate::RowId).collect(),
5199                },
5200            };
5201            records.push(crate::wal::Record::new(Epoch(0), txn_id, op));
5202        }
5203        let epoch = self.epoch.visible().0 + 1;
5204        // The physical component of the decision's commit timestamp goes into
5205        // the durable timestamp ledger, mirroring the ordinary commit path
5206        // (`commit_log::commit_nanos`).
5207        let unix_nanos = commit_ts.physical_micros.saturating_mul(1_000);
5208        records.push(crate::wal::Record::new(
5209            Epoch(0),
5210            txn_id,
5211            Op::CommitTimestamp { unix_nanos },
5212        ));
5213        records.push(crate::wal::Record::new(
5214            Epoch(0),
5215            txn_id,
5216            Op::TxnCommit {
5217                epoch,
5218                added_runs: Vec::new(),
5219            },
5220        ));
5221        let applied = self.apply_replicated_records(&records)?;
5222        if applied && commit_ts != mongreldb_types::hlc::HlcTimestamp::ZERO {
5223            // Ledger + recovery restamp use the shared decision HLC so every
5224            // replica observes the identical commit_ts (P0.5-T4 / P0.5-X1).
5225            self.record_commit_ts(Epoch(epoch), commit_ts);
5226        }
5227        Ok(applied)
5228    }
5229
5230    /// Apply a committed distributed transaction at a shared `commit_ts`
5231    /// (P0.5-T4). Replicas must not allocate a local replacement timestamp —
5232    /// the caller's decision HLC goes into the durable ledger, and every row
5233    /// version recovered from the synthetic WAL records is restamped from the
5234    /// transaction's `Op::CommitTimestamp` record (physical micros; the WAL
5235    /// `Put` payload cannot carry the full HLC under the 0.63.1 layout).
5236    pub fn apply_committed_transaction(
5237        &self,
5238        transaction_id: u64,
5239        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5240        operations: &[Vec<u8>],
5241    ) -> Result<bool> {
5242        self.apply_staged_txn_writes(transaction_id, operations, commit_ts)
5243    }
5244
5245    /// Stage 2E (spec sections 10.6, 11.5): apply one committed replicated
5246    /// catalog command and checkpoint the catalog. The record travels as the
5247    /// payload of a replicated `Catalog` command envelope and routes through
5248    /// [`Catalog::apply_command`] — the S1F-001 versioned, idempotent command
5249    /// path (replaying an already-applied `catalog_version` is a no-op).
5250    /// Structural deltas are mirrored into the mounted table set: a created
5251    /// table is mounted, a dropped table unmounted. Deterministic: the record
5252    /// carries every resolved value (ids, epochs, complete images).
5253    pub fn apply_replicated_catalog_command(
5254        &self,
5255        record: &crate::catalog_cmds::CatalogCommandRecord,
5256    ) -> Result<crate::catalog_cmds::CatalogDelta> {
5257        let _operation = self.admit_operation()?;
5258        let _g = self.ddl_lock.lock();
5259        let mut next_catalog = self.catalog.read().clone();
5260        let delta = next_catalog.apply_command(record)?;
5261        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
5262            return Ok(delta);
5263        }
5264        // The leader references epochs in structural commands (a table's
5265        // creation/drop epoch). The replica's epoch stream must cover them:
5266        // epochs stay the commit sequencer's authority, so commands never
5267        // allocate one, but the watermark advances to every referenced epoch
5268        // (mirroring how `create_table_with_state` bumps `db_epoch`).
5269        let referenced_epoch = match &delta {
5270            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => Some(entry.created_epoch),
5271            crate::catalog_cmds::CatalogDelta::TableDropped { at_epoch, .. }
5272            | crate::catalog_cmds::CatalogDelta::TableRenamed { at_epoch, .. } => Some(*at_epoch),
5273            _ => None,
5274        };
5275        if let Some(referenced) = referenced_epoch {
5276            self.epoch.advance_recovered(Epoch(referenced));
5277            next_catalog.db_epoch = next_catalog.db_epoch.max(referenced);
5278        }
5279        match &delta {
5280            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => {
5281                // Guard against a repeated mount within one open (the state
5282                // machine's crash-window redispatch is filtered by the NoOp
5283                // arm above; this covers a catalog checkpoint that never
5284                // became durable before a crash).
5285                if !self.tables.read().contains_key(&entry.table_id) {
5286                    self.mount_catalog_entry(entry)?;
5287                }
5288            }
5289            crate::catalog_cmds::CatalogDelta::TableDropped { table_id, .. } => {
5290                self.tables.write().remove(table_id);
5291            }
5292            _ => {}
5293        }
5294        // Durable BEFORE return: the catalog checkpoint is the only local
5295        // durable record of the command (replicated catalog commands do not
5296        // ride the local WAL), and the state machine checkpoints right after.
5297        catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref())?;
5298        *self.catalog.write() = next_catalog;
5299        Ok(delta)
5300    }
5301
5302    /// Mount a table that a replicated catalog command created (Stage 2E):
5303    /// create the table directory and build the mounted table exactly like
5304    /// the create-table path does, minus the standalone WAL/commit side
5305    /// effects (the command itself is already durable in the raft log).
5306    fn mount_catalog_entry(&self, entry: &crate::catalog::CatalogEntry) -> Result<()> {
5307        let table_relative = Path::new(TABLES_DIR).join(entry.table_id.to_string());
5308        let table_root = Arc::new(
5309            self.durable_root
5310                .create_directory_all_pinned(&table_relative)?,
5311        );
5312        let tdir = table_root.io_path()?;
5313        let ctx = SharedCtx {
5314            root_guard: Some(table_root),
5315            epoch: Arc::clone(&self.epoch),
5316            page_cache: Arc::clone(&self.page_cache),
5317            decoded_cache: Arc::clone(&self.decoded_cache),
5318            snapshots: Arc::clone(&self.snapshots),
5319            kek: self.kek.clone(),
5320            commit_lock: Arc::clone(&self.commit_lock),
5321            shared: Some(crate::engine::SharedWalCtx {
5322                wal: Arc::clone(&self.shared_wal),
5323                group: Arc::clone(&self.group),
5324                poisoned: Arc::clone(&self.poisoned),
5325                txn_ids: Arc::clone(&self.next_txn_id),
5326                change_wake: self.change_wake.clone(),
5327                lifecycle: Arc::clone(&self.lifecycle),
5328                hlc: Arc::clone(&self.hlc),
5329            }),
5330            table_name: Some(entry.name.clone()),
5331            auth: self.table_auth_checker(),
5332            read_only: self.read_only,
5333        };
5334        let table = Table::create_in(&tdir, entry.schema.clone(), entry.table_id, ctx)?;
5335        self.tables
5336            .write()
5337            .insert(entry.table_id, TableHandle::new(table));
5338        Ok(())
5339    }
5340
5341    fn publish_catalog_candidate(
5342        &self,
5343        catalog: Catalog,
5344        epoch: Epoch,
5345        epoch_guard: &mut EpochGuard<'_>,
5346        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5347    ) -> Result<()> {
5348        self.publish_catalog_candidate_with_prelude(
5349            catalog,
5350            epoch,
5351            epoch_guard,
5352            before_publish,
5353            Vec::new(),
5354        )
5355    }
5356
5357    fn publish_catalog_candidate_with_prelude(
5358        &self,
5359        catalog: Catalog,
5360        epoch: Epoch,
5361        epoch_guard: &mut EpochGuard<'_>,
5362        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5363        prelude: Vec<(u64, crate::wal::Op)>,
5364    ) -> Result<()> {
5365        use crate::wal::DdlOp;
5366
5367        if self.read_only {
5368            return Err(MongrelError::ReadOnlyReplica);
5369        }
5370        if self.poisoned.load(Ordering::Relaxed) {
5371            return Err(MongrelError::Other(
5372                "database poisoned by fsync error".into(),
5373            ));
5374        }
5375        // S1A-004: admit the catalog publish as one core operation.
5376        let _operation = self.admit_operation()?;
5377        if let Some(before_publish) = before_publish.as_mut() {
5378            (**before_publish)()?;
5379        }
5380        if catalog.db_epoch != epoch.0 {
5381            return Err(MongrelError::InvalidArgument(format!(
5382                "catalog epoch {} does not match commit epoch {}",
5383                catalog.db_epoch, epoch.0
5384            )));
5385        }
5386        {
5387            let current = self.catalog.read();
5388            validate_catalog_transition(&current, &catalog)?;
5389        }
5390        validate_recovered_catalog(&catalog)?;
5391        let catalog_json = DdlOp::encode_catalog(&catalog)?;
5392        let txn_id = self.alloc_txn_id()?;
5393        let commit_seq = {
5394            let mut wal = self.shared_wal.lock();
5395            let append: Result<u64> = (|| {
5396                for (table_id, op) in prelude {
5397                    wal.append(txn_id, table_id, op)?;
5398                }
5399                wal.append(
5400                    txn_id,
5401                    WAL_TABLE_ID,
5402                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
5403                )?;
5404                wal.append_commit(txn_id, epoch, &[])
5405            })();
5406            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5407        };
5408        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5409        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
5410        self.finish_durable_publish(epoch, epoch_guard, &receipt, checkpoint)
5411    }
5412
5413    /// A WAL commit is already durable. Publish the matching catalog in memory
5414    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
5415    /// while the live handle must never continue with pre-commit metadata.
5416    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
5417        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
5418        let version = catalog.security_version;
5419        let principal = self.principal.read().clone();
5420        let principal = if catalog.require_auth
5421            && principal
5422                .as_ref()
5423                .is_some_and(|principal| principal.user_id != 0)
5424        {
5425            principal.as_ref().and_then(|principal| {
5426                Self::resolve_bound_principal_from_catalog(&catalog, principal)
5427            })
5428        } else {
5429            principal
5430        };
5431        *self.catalog.write() = catalog;
5432        self.security_coordinator
5433            .version
5434            .store(version, Ordering::Release);
5435        self.auth_state
5436            .set_require_auth(self.catalog.read().require_auth);
5437        *self.principal.write() = principal.clone();
5438        self.auth_state.set_principal(principal);
5439        checkpoint
5440    }
5441
5442    fn finish_durable_publish(
5443        &self,
5444        epoch: Epoch,
5445        epoch_guard: &mut EpochGuard<'_>,
5446        receipt: &mongreldb_log::CommitReceipt,
5447        post_step: Result<()>,
5448    ) -> Result<()> {
5449        if let Err(error) = self.publish_committed(receipt, epoch) {
5450            // The commit marker is durable but runtime publication failed. The
5451            // epoch guard stays armed so the assigned ticket is abandoned (the
5452            // watermark skips it), and the live handle poisons exactly like any
5453            // other post-durable failure.
5454            self.poisoned.store(true, Ordering::Relaxed);
5455            self.lifecycle.poison();
5456            return Err(MongrelError::DurableCommit {
5457                epoch: epoch.0,
5458                message: error.to_string(),
5459            });
5460        }
5461        epoch_guard.disarm();
5462        match post_step {
5463            Ok(()) => Ok(()),
5464            Err(error) => {
5465                self.poisoned.store(true, Ordering::Relaxed);
5466                self.lifecycle.poison();
5467                Err(MongrelError::DurableCommit {
5468                    epoch: epoch.0,
5469                    message: error.to_string(),
5470                })
5471            }
5472        }
5473    }
5474
5475    /// Advance reader visibility for a committed epoch. Publication is gated on
5476    /// the commit log's receipt (spec §9.4, FND-004): visibility only ever
5477    /// covers commands the commit log acknowledged durable. The
5478    /// `commit.publish.before`/`commit.publish.after` fault hooks bracket the
5479    /// watermark advance (spec §9.6, FND-006).
5480    fn publish_committed(
5481        &self,
5482        receipt: &mongreldb_log::CommitReceipt,
5483        epoch: Epoch,
5484    ) -> Result<()> {
5485        debug_assert_eq!(
5486            receipt.log_position.index, epoch.0,
5487            "commit receipt position must match the published epoch"
5488        );
5489        mongreldb_fault::inject("commit.publish.before").map_err(crate::commit_log::fault_as_io)?;
5490        self.epoch.publish_in_order(epoch);
5491        mongreldb_fault::inject("commit.publish.after").map_err(crate::commit_log::fault_as_io)?;
5492        Ok(())
5493    }
5494
5495    /// Wait for a commit marker to reach stable storage and return the commit
5496    /// log's irrevocable receipt (spec §9.4, FND-004). A failed append/fsync
5497    /// acknowledgement is ambiguous, so poison the live handle and preserve
5498    /// the assigned epoch in a structured unknown-outcome error.
5499    ///
5500    /// Used by the DDL/maintenance commit paths, which do not pre-assign a
5501    /// commit timestamp: the receipt's `commit_ts` is allocated from the
5502    /// node's HLC clock at seal time.
5503    fn await_durable_commit(
5504        &self,
5505        txn_id: u64,
5506        commit_seq: u64,
5507        epoch: Epoch,
5508    ) -> Result<mongreldb_log::CommitReceipt> {
5509        match self
5510            .standalone_commit_log
5511            .seal_transaction(txn_id, epoch, commit_seq, None)
5512        {
5513            Ok(receipt) => {
5514                self.record_commit_ts(epoch, receipt.commit_ts);
5515                Ok(receipt)
5516            }
5517            Err(error) => {
5518                self.poisoned.store(true, Ordering::Relaxed);
5519                self.lifecycle.poison();
5520                Err(MongrelError::CommitOutcomeUnknown {
5521                    epoch: epoch.0,
5522                    message: error.to_string(),
5523                })
5524            }
5525        }
5526    }
5527
5528    /// [`Self::await_durable_commit`] for the transaction commit sequencer,
5529    /// which assigned `commit_ts` under the sequencer lock (S1B-004 step 5,
5530    /// spec §8.2). The receipt carries that exact timestamp, matching the
5531    /// durable `Op::CommitTimestamp` ledger record written at append.
5532    fn await_durable_commit_with_ts(
5533        &self,
5534        txn_id: u64,
5535        commit_seq: u64,
5536        epoch: Epoch,
5537        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5538    ) -> Result<mongreldb_log::CommitReceipt> {
5539        match self.standalone_commit_log.seal_transaction(
5540            txn_id,
5541            epoch,
5542            commit_seq,
5543            Some(commit_ts),
5544        ) {
5545            Ok(receipt) => {
5546                self.record_commit_ts(epoch, receipt.commit_ts);
5547                Ok(receipt)
5548            }
5549            Err(error) => {
5550                self.poisoned.store(true, Ordering::Relaxed);
5551                self.lifecycle.poison();
5552                Err(MongrelError::CommitOutcomeUnknown {
5553                    epoch: epoch.0,
5554                    message: error.to_string(),
5555                })
5556            }
5557        }
5558    }
5559
5560    /// Record one durable commit's receipt timestamp in the per-open ledger
5561    /// (bounded to the newest [`COMMIT_TS_LEDGER_CAP`] epochs). Called by the
5562    /// durability funnels once the commit log has issued the irrevocable
5563    /// receipt.
5564    fn record_commit_ts(&self, epoch: Epoch, commit_ts: mongreldb_types::hlc::HlcTimestamp) {
5565        let mut ledger = self.commit_ts_ledger.lock();
5566        ledger.insert(epoch.0, commit_ts);
5567        while ledger.len() > COMMIT_TS_LEDGER_CAP {
5568            ledger.pop_first();
5569        }
5570    }
5571
5572    /// The commit timestamp of a durable commit, by epoch — the literal
5573    /// write receipt behind the server's read-your-writes token (spec §8.2).
5574    ///
5575    /// Returns `Some` for commits sealed within this open (the exact receipt
5576    /// `HlcTimestamp`) and for commits recovered from the durable
5577    /// `Op::CommitTimestamp` WAL ledger at open; the latter reconstruct the
5578    /// physical component only, with `logical` and `node_tiebreaker` as 0 per
5579    /// the ledger byte format. Only the newest [`COMMIT_TS_LEDGER_CAP`]
5580    /// epochs are retained, and epochs sealed through `CommitLog::propose`
5581    /// (catalog-command proposals) are not recorded here within a live open;
5582    /// both miss shapes return `None`, and callers fall back to a fresh-begin
5583    /// HLC, which the single clock authority orders after every commit it has
5584    /// already issued.
5585    pub fn commit_ts_for_epoch(&self, epoch: Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp> {
5586        self.commit_ts_ledger.lock().get(&epoch.0).copied()
5587    }
5588
5589    /// Newest retained commit epoch whose HLC timestamp is not after `timestamp`.
5590    ///
5591    /// The mapping is bounded by [`COMMIT_TS_LEDGER_CAP`]. Callers needing an
5592    /// older historical point must treat `None` as unavailable, never guess an
5593    /// epoch.
5594    pub fn epoch_at_or_before_commit_ts(
5595        &self,
5596        timestamp: mongreldb_types::hlc::HlcTimestamp,
5597    ) -> Option<Epoch> {
5598        self.commit_ts_ledger
5599            .lock()
5600            .iter()
5601            .rev()
5602            .find_map(|(epoch, commit_ts)| (*commit_ts <= timestamp).then_some(Epoch(*epoch)))
5603    }
5604
5605    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
5606        self.poisoned.store(true, Ordering::Relaxed);
5607        self.lifecycle.poison();
5608        MongrelError::CommitOutcomeUnknown {
5609            epoch: epoch.0,
5610            message: error.to_string(),
5611        }
5612    }
5613
5614    /// Persist a complete validated RLS/masking catalog through the WAL.
5615    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
5616        self.set_security_catalog_as_with_epoch(security, None)
5617            .map(|_| ())
5618    }
5619
5620    /// Persist security policy changes on behalf of an explicit request principal.
5621    pub fn set_security_catalog_as(
5622        &self,
5623        security: crate::security::SecurityCatalog,
5624        principal: Option<&crate::auth::Principal>,
5625    ) -> Result<()> {
5626        self.set_security_catalog_as_with_epoch(security, principal)
5627            .map(|_| ())
5628    }
5629
5630    /// Persist security policy changes and return the exact publication epoch.
5631    pub fn set_security_catalog_as_with_epoch(
5632        &self,
5633        security: crate::security::SecurityCatalog,
5634        principal: Option<&crate::auth::Principal>,
5635    ) -> Result<Epoch> {
5636        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
5637    }
5638
5639    /// Persist security policy changes, entering the commit fence immediately
5640    /// before the first WAL record can become visible to recovery.
5641    pub fn set_security_catalog_as_with_epoch_controlled<F>(
5642        &self,
5643        security: crate::security::SecurityCatalog,
5644        principal: Option<&crate::auth::Principal>,
5645        mut before_commit: F,
5646    ) -> Result<Epoch>
5647    where
5648        F: FnMut() -> Result<()>,
5649    {
5650        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
5651    }
5652
5653    fn set_security_catalog_as_with_epoch_inner(
5654        &self,
5655        security: crate::security::SecurityCatalog,
5656        principal: Option<&crate::auth::Principal>,
5657        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
5658    ) -> Result<Epoch> {
5659        use crate::wal::DdlOp;
5660        use std::sync::atomic::Ordering;
5661
5662        // S1F-001: the mutation is a versioned catalog command; its required
5663        // permission is checked against the caller principal first (identical
5664        // to the legacy hardcoded Admin gate).
5665        let command = crate::catalog_cmds::CatalogCommand::SetSecurityCatalog {
5666            security: security.clone(),
5667        };
5668        self.require_for(
5669            principal,
5670            &crate::catalog_cmds::required_permission(&command),
5671        )?;
5672        if self.poisoned.load(Ordering::Relaxed) {
5673            return Err(MongrelError::Other(
5674                "database poisoned by fsync error".into(),
5675            ));
5676        }
5677        // S1A-004: admit the security-catalog replacement as one core
5678        // operation.
5679        let _operation = self.admit_operation()?;
5680        let _ddl = self.ddl_lock.lock();
5681        // DDL serializes first; write-path order after that is security gate ->
5682        // commit lock -> shared WAL.
5683        let _security_write = self.security_write()?;
5684        self.require_for(
5685            principal,
5686            &crate::catalog_cmds::required_permission(&command),
5687        )?;
5688        let mut next_catalog = self.catalog.read().clone();
5689        validate_security_catalog(&next_catalog, &security)?;
5690        let payload = DdlOp::encode_security(&security)?;
5691        let _commit = self.commit_lock.lock();
5692        let epoch = self.epoch.bump_assigned();
5693        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5694        let txn_id = self.alloc_txn_id()?;
5695        self.apply_catalog_command_to(&mut next_catalog, command)?;
5696        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
5697        let commit_seq = {
5698            let mut wal = self.shared_wal.lock();
5699            if let Some(before_commit) = before_commit {
5700                before_commit()?;
5701            }
5702            let append: Result<u64> = (|| {
5703                wal.append(
5704                    txn_id,
5705                    WAL_TABLE_ID,
5706                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
5707                        security_json: payload,
5708                    }),
5709                )?;
5710                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
5711                wal.append_commit(txn_id, epoch, &[])
5712            })();
5713            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5714        };
5715        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5716        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
5717        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
5718        Ok(epoch)
5719    }
5720
5721    pub fn require_for(
5722        &self,
5723        principal: Option<&crate::auth::Principal>,
5724        permission: &crate::auth::Permission,
5725    ) -> Result<()> {
5726        let Some(principal) = principal else {
5727            return self.require(permission);
5728        };
5729        let resolved;
5730        let principal = if principal.user_id != 0 {
5731            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5732                .ok_or(MongrelError::AuthRequired)?;
5733            &resolved
5734        } else {
5735            principal
5736        };
5737        #[cfg(test)]
5738        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5739        if principal.has_permission(permission) {
5740            Ok(())
5741        } else {
5742            Err(MongrelError::PermissionDenied {
5743                required: permission.clone(),
5744                principal: principal.username.clone(),
5745            })
5746        }
5747    }
5748
5749    /// Recheck the exact operation principal while the caller holds the
5750    /// security gate. This deliberately performs no refresh or nested gate
5751    /// acquisition.
5752    fn require_exact_principal_current(
5753        &self,
5754        principal: Option<&crate::auth::Principal>,
5755        permission: &crate::auth::Permission,
5756    ) -> Result<()> {
5757        let catalog = self.catalog.read();
5758        if !catalog.require_auth {
5759            return Ok(());
5760        }
5761        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
5762        let current = if supplied.user_id == 0 {
5763            supplied.clone()
5764        } else {
5765            Self::resolve_bound_principal_from_catalog(&catalog, supplied)
5766                .ok_or(MongrelError::AuthRequired)?
5767        };
5768        if current.has_permission(permission) {
5769            Ok(())
5770        } else {
5771            Err(MongrelError::PermissionDenied {
5772                required: permission.clone(),
5773                principal: current.username,
5774            })
5775        }
5776    }
5777
5778    pub(crate) fn with_exact_principal_current<T, F>(
5779        &self,
5780        principal: Option<&crate::auth::Principal>,
5781        permission: &crate::auth::Permission,
5782        operation: F,
5783    ) -> Result<T>
5784    where
5785        F: FnOnce() -> Result<T>,
5786    {
5787        let _security = self.security_coordinator.gate.read();
5788        self.require_exact_principal_current(principal, permission)?;
5789        operation()
5790    }
5791
5792    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
5793        self.principal.read().clone()
5794    }
5795
5796    #[cfg(test)]
5797    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
5798        *self.principal.write() = principal.clone();
5799        self.auth_state.set_principal(principal);
5800    }
5801
5802    pub fn require_columns_for(
5803        &self,
5804        table: &str,
5805        operation: crate::auth::ColumnOperation,
5806        column_ids: &[u16],
5807        principal: Option<&crate::auth::Principal>,
5808    ) -> Result<()> {
5809        if principal.is_none() && !self.auth_state.require_auth() {
5810            return Ok(());
5811        }
5812        let cached = self.principal.read().clone();
5813        let principal = principal.or(cached.as_ref());
5814        let Some(principal) = principal else {
5815            let permission = match operation {
5816                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5817                    table: table.to_string(),
5818                },
5819                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5820                    table: table.to_string(),
5821                },
5822                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5823                    table: table.to_string(),
5824                },
5825            };
5826            return self.require(&permission);
5827        };
5828        let catalog = self.catalog.read();
5829        let resolved;
5830        let principal = if principal.user_id != 0 {
5831            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
5832                .ok_or(MongrelError::AuthRequired)?;
5833            &resolved
5834        } else {
5835            principal
5836        };
5837        let schema = &catalog
5838            .live(table)
5839            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5840            .schema;
5841        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
5842    }
5843
5844    fn require_columns_for_principal(
5845        table: &str,
5846        schema: &Schema,
5847        operation: crate::auth::ColumnOperation,
5848        column_ids: &[u16],
5849        principal: &crate::auth::Principal,
5850    ) -> Result<()> {
5851        #[cfg(test)]
5852        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5853        match principal.column_access(table, operation) {
5854            crate::auth::ColumnAccess::All => Ok(()),
5855            crate::auth::ColumnAccess::Columns(allowed) => {
5856                let denied = column_ids.iter().find_map(|column_id| {
5857                    schema
5858                        .columns
5859                        .iter()
5860                        .find(|column| column.id == *column_id)
5861                        .filter(|column| !allowed.contains(&column.name))
5862                });
5863                if denied.is_none() {
5864                    Ok(())
5865                } else {
5866                    Err(MongrelError::PermissionDenied {
5867                        required: match operation {
5868                            crate::auth::ColumnOperation::Select => {
5869                                crate::auth::Permission::SelectColumns {
5870                                    table: table.to_string(),
5871                                    columns: denied
5872                                        .into_iter()
5873                                        .map(|column| column.name.clone())
5874                                        .collect(),
5875                                }
5876                            }
5877                            crate::auth::ColumnOperation::Insert => {
5878                                crate::auth::Permission::InsertColumns {
5879                                    table: table.to_string(),
5880                                    columns: denied
5881                                        .into_iter()
5882                                        .map(|column| column.name.clone())
5883                                        .collect(),
5884                                }
5885                            }
5886                            crate::auth::ColumnOperation::Update => {
5887                                crate::auth::Permission::UpdateColumns {
5888                                    table: table.to_string(),
5889                                    columns: denied
5890                                        .into_iter()
5891                                        .map(|column| column.name.clone())
5892                                        .collect(),
5893                                }
5894                            }
5895                        },
5896                        principal: principal.username.clone(),
5897                    })
5898                }
5899            }
5900            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5901                required: match operation {
5902                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5903                        table: table.to_string(),
5904                    },
5905                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5906                        table: table.to_string(),
5907                    },
5908                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5909                        table: table.to_string(),
5910                    },
5911                },
5912                principal: principal.username.clone(),
5913            }),
5914        }
5915    }
5916
5917    pub fn select_column_ids_for(
5918        &self,
5919        table: &str,
5920        principal: Option<&crate::auth::Principal>,
5921    ) -> Result<Vec<u16>> {
5922        let catalog = self.catalog.read();
5923        let columns = catalog
5924            .live(table)
5925            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5926            .schema
5927            .columns
5928            .iter()
5929            .map(|column| (column.id, column.name.clone()))
5930            .collect::<Vec<_>>();
5931        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
5932        drop(catalog);
5933        let Some(principal) = principal.as_ref() else {
5934            self.require(&crate::auth::Permission::Select {
5935                table: table.to_string(),
5936            })?;
5937            return Ok(columns.iter().map(|(id, _)| *id).collect());
5938        };
5939        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
5940            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
5941            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
5942                .iter()
5943                .filter(|(_, name)| allowed.contains(name))
5944                .map(|(id, _)| *id)
5945                .collect()),
5946            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5947                required: crate::auth::Permission::Select {
5948                    table: table.to_string(),
5949                },
5950                principal: principal.username.clone(),
5951            }),
5952        }
5953    }
5954
5955    pub fn secure_rows_for(
5956        &self,
5957        table: &str,
5958        rows: Vec<crate::memtable::Row>,
5959        principal: Option<&crate::auth::Principal>,
5960    ) -> Result<Vec<crate::memtable::Row>> {
5961        self.secure_rows_for_with_context(table, rows, principal, None)
5962    }
5963
5964    pub fn secure_rows_for_with_context(
5965        &self,
5966        table: &str,
5967        rows: Vec<crate::memtable::Row>,
5968        principal: Option<&crate::auth::Principal>,
5969        context: Option<&crate::query::AiExecutionContext>,
5970    ) -> Result<Vec<crate::memtable::Row>> {
5971        let (security, principal) = {
5972            let catalog = self.catalog.read();
5973            (
5974                catalog.security.clone(),
5975                self.principal_for_authorized_read(&catalog, principal, false)?,
5976            )
5977        };
5978        if !security.table_has_security(table) {
5979            return Ok(rows);
5980        }
5981        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5982        let mut output = Vec::new();
5983        for mut row in rows {
5984            if let Some(context) = context {
5985                context.consume(1)?;
5986            }
5987            if security.row_allowed(
5988                table,
5989                crate::security::PolicyCommand::Select,
5990                &row,
5991                principal,
5992                false,
5993            ) {
5994                security.apply_masks(table, &mut row, principal);
5995                output.push(row);
5996            }
5997        }
5998        Ok(output)
5999    }
6000
6001    /// Apply column masks to already RLS-authorized scored hits without a
6002    /// second row gather or policy evaluation.
6003    pub fn mask_search_hits_for(
6004        &self,
6005        table: &str,
6006        hits: &mut [crate::query::SearchHit],
6007        principal: Option<&crate::auth::Principal>,
6008    ) -> Result<()> {
6009        let (security, principal) = {
6010            let catalog = self.catalog.read();
6011            (
6012                catalog.security.clone(),
6013                self.principal_for_authorized_read(&catalog, principal, false)?,
6014            )
6015        };
6016        if !security.table_has_security(table) {
6017            return Ok(());
6018        }
6019        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
6020        for hit in hits {
6021            security.apply_masks_to_cells(table, &mut hit.cells, principal);
6022        }
6023        Ok(())
6024    }
6025
6026    /// Apply masks to rows already admitted by candidate-aware RLS.
6027    pub fn mask_rows_for(
6028        &self,
6029        table: &str,
6030        rows: &mut [crate::memtable::Row],
6031        principal: Option<&crate::auth::Principal>,
6032    ) -> Result<()> {
6033        let (security, principal) = {
6034            let catalog = self.catalog.read();
6035            (
6036                catalog.security.clone(),
6037                self.principal_for_authorized_read(&catalog, principal, false)?,
6038            )
6039        };
6040        if !security.table_has_security(table) {
6041            return Ok(());
6042        }
6043        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
6044        for row in rows {
6045            security.apply_masks(table, row, principal);
6046        }
6047        Ok(())
6048    }
6049
6050    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
6051    pub fn authorized_candidate_ids_for(
6052        &self,
6053        table: &str,
6054        principal: Option<&crate::auth::Principal>,
6055    ) -> Result<Option<std::collections::HashSet<RowId>>> {
6056        Ok(self
6057            .authorized_read_snapshot(table, principal)?
6058            .allowed_row_ids)
6059    }
6060
6061    fn allowed_row_ids_locked(
6062        &self,
6063        table_name: &str,
6064        table: &Table,
6065        table_snapshot: Snapshot,
6066        security_state: (&crate::security::SecurityCatalog, u64),
6067        principal: Option<&crate::auth::Principal>,
6068        context: Option<&crate::query::AiExecutionContext>,
6069    ) -> Result<Option<Arc<HashSet<RowId>>>> {
6070        let (security, security_version) = security_state;
6071        if !security.rls_enabled(table_name) {
6072            return Ok(None);
6073        }
6074        let authorization_started = std::time::Instant::now();
6075        let principal = principal.ok_or(MongrelError::AuthRequired)?;
6076        let mut roles = principal.roles.clone();
6077        roles.sort_unstable();
6078        let principal_key = format!(
6079            "{}:{}:{}:{}:{roles:?}",
6080            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
6081        );
6082        let cache_key = (
6083            table_name.to_string(),
6084            table.data_generation(),
6085            security_version,
6086            principal_key,
6087        );
6088        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
6089            crate::trace::QueryTrace::record(|trace| {
6090                trace.rls_cache_hit = true;
6091                trace.authorization_nanos = trace
6092                    .authorization_nanos
6093                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
6094            });
6095            return Ok(Some(allowed));
6096        }
6097        if let Some(context) = context {
6098            context.checkpoint()?;
6099        }
6100        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
6101        let started = std::time::Instant::now();
6102        let rows = table.visible_rows(table_snapshot)?;
6103        let rows_evaluated = rows.len() as u64;
6104        let mut allowed = HashSet::new();
6105        for chunk in rows.chunks(256) {
6106            if let Some(context) = context {
6107                context.consume(chunk.len())?;
6108            }
6109            allowed.extend(chunk.iter().filter_map(|row| {
6110                security
6111                    .row_allowed(
6112                        table_name,
6113                        crate::security::PolicyCommand::Select,
6114                        row,
6115                        principal,
6116                        false,
6117                    )
6118                    .then_some(row.row_id)
6119            }));
6120        }
6121        let allowed = Arc::new(allowed);
6122        let mut cache = self.rls_cache.lock();
6123        cache.build_nanos = cache
6124            .build_nanos
6125            .saturating_add(started.elapsed().as_nanos() as u64);
6126        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
6127        cache.insert(cache_key, Arc::clone(&allowed));
6128        crate::trace::QueryTrace::record(|trace| {
6129            trace.rls_rows_evaluated = trace
6130                .rls_rows_evaluated
6131                .saturating_add(rows_evaluated as usize);
6132            trace.authorization_nanos = trace
6133                .authorization_nanos
6134                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
6135        });
6136        Ok(Some(allowed))
6137    }
6138
6139    fn principal_for_authorized_read(
6140        &self,
6141        catalog: &Catalog,
6142        principal: Option<&crate::auth::Principal>,
6143        catalog_bound: bool,
6144    ) -> Result<Option<crate::auth::Principal>> {
6145        let principal = principal.cloned().or_else(|| self.principal.read().clone());
6146        let Some(principal) = principal else {
6147            return Ok(None);
6148        };
6149        if catalog_bound || principal.user_id != 0 {
6150            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
6151                .map(Some)
6152                .ok_or(MongrelError::AuthRequired);
6153        }
6154        Ok(Some(principal))
6155    }
6156
6157    /// Run authorization, candidate generation, ranking, and materialization
6158    /// while holding one table generation. Security changes cause a bounded
6159    /// retry before any result is published.
6160    pub fn with_authorized_read<T, F>(
6161        &self,
6162        table_name: &str,
6163        principal: Option<&crate::auth::Principal>,
6164        catalog_bound: bool,
6165        read: F,
6166    ) -> Result<T>
6167    where
6168        F: FnMut(
6169            &mut Table,
6170            Snapshot,
6171            Option<&HashSet<RowId>>,
6172            Option<&crate::auth::Principal>,
6173        ) -> Result<T>,
6174    {
6175        self.with_authorized_read_context(
6176            table_name,
6177            principal,
6178            catalog_bound,
6179            None,
6180            None,
6181            None,
6182            read,
6183        )
6184    }
6185
6186    #[allow(clippy::too_many_arguments)]
6187    pub fn with_authorized_read_context<T, F>(
6188        &self,
6189        table_name: &str,
6190        principal: Option<&crate::auth::Principal>,
6191        catalog_bound: bool,
6192        authorization: Option<&ReadAuthorization>,
6193        context: Option<&crate::query::AiExecutionContext>,
6194        snapshot_override: Option<Snapshot>,
6195        read: F,
6196    ) -> Result<T>
6197    where
6198        F: FnMut(
6199            &mut Table,
6200            Snapshot,
6201            Option<&HashSet<RowId>>,
6202            Option<&crate::auth::Principal>,
6203        ) -> Result<T>,
6204    {
6205        self.with_authorized_read_context_stamped(
6206            table_name,
6207            principal,
6208            catalog_bound,
6209            authorization,
6210            context,
6211            snapshot_override,
6212            read,
6213        )
6214        .map(|(result, _)| result)
6215    }
6216
6217    #[allow(clippy::too_many_arguments)]
6218    pub fn with_authorized_read_context_stamped<T, F>(
6219        &self,
6220        table_name: &str,
6221        principal: Option<&crate::auth::Principal>,
6222        catalog_bound: bool,
6223        authorization: Option<&ReadAuthorization>,
6224        context: Option<&crate::query::AiExecutionContext>,
6225        snapshot_override: Option<Snapshot>,
6226        mut read: F,
6227    ) -> Result<(T, AuthorizedReadStamp)>
6228    where
6229        F: FnMut(
6230            &mut Table,
6231            Snapshot,
6232            Option<&HashSet<RowId>>,
6233            Option<&crate::auth::Principal>,
6234        ) -> Result<T>,
6235    {
6236        if principal.is_none() && self.principal.read().is_some() {
6237            self.refresh_principal()?;
6238        }
6239        const RETRIES: usize = 3;
6240        let handle = self.table(table_name)?;
6241        for attempt in 0..RETRIES {
6242            crate::trace::QueryTrace::record(|trace| {
6243                trace.authorization_retries = attempt;
6244            });
6245            let (security, security_version, effective_principal) = {
6246                let catalog = self.catalog.read();
6247                (
6248                    catalog.security.clone(),
6249                    catalog.security_version,
6250                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6251                )
6252            };
6253            if let Some(authorization) = authorization {
6254                for permission in &authorization.permissions {
6255                    self.require_for(effective_principal.as_ref(), permission)?;
6256                }
6257                self.require_columns_for(
6258                    table_name,
6259                    authorization.operation,
6260                    &authorization.columns,
6261                    effective_principal.as_ref(),
6262                )?;
6263            }
6264            let result = {
6265                let mut table = lock_table_with_context(&handle, context)?;
6266                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
6267                let allowed = self.allowed_row_ids_locked(
6268                    table_name,
6269                    &table,
6270                    snapshot,
6271                    (&security, security_version),
6272                    effective_principal.as_ref(),
6273                    context,
6274                )?;
6275                let stamp = AuthorizedReadStamp {
6276                    table_id: table.table_id(),
6277                    schema_id: table.schema().schema_id,
6278                    data_generation: table.data_generation(),
6279                    security_version,
6280                    snapshot,
6281                };
6282                let result = read(
6283                    &mut table,
6284                    snapshot,
6285                    allowed.as_deref(),
6286                    effective_principal.as_ref(),
6287                )?;
6288                (result, stamp)
6289            };
6290            if let Some(context) = context {
6291                context.checkpoint()?;
6292            }
6293            if self.catalog.read().security_version == security_version {
6294                return Ok(result);
6295            }
6296            if attempt + 1 == RETRIES {
6297                return Err(MongrelError::Conflict(
6298                    "security policy changed during scored read".into(),
6299                ));
6300            }
6301        }
6302        Err(MongrelError::Conflict(
6303            "authorization retry loop exhausted".into(),
6304        ))
6305    }
6306
6307    fn with_authorized_aggregate_table<T, F>(
6308        &self,
6309        table_name: &str,
6310        columns: &[u16],
6311        principal: Option<&crate::auth::Principal>,
6312        catalog_bound: bool,
6313        allow_table_security: bool,
6314        mut aggregate: F,
6315    ) -> Result<T>
6316    where
6317        F: FnMut(
6318            &mut Table,
6319            Option<&crate::security::CandidateAuthorization<'_>>,
6320            Option<&crate::auth::Principal>,
6321            u64,
6322        ) -> Result<T>,
6323    {
6324        if principal.is_none() && self.principal.read().is_some() {
6325            self.refresh_principal()?;
6326        }
6327        const RETRIES: usize = 3;
6328        let handle = self.table(table_name)?;
6329        for attempt in 0..RETRIES {
6330            let (security, security_version, effective_principal) = {
6331                let catalog = self.catalog.read();
6332                (
6333                    catalog.security.clone(),
6334                    catalog.security_version,
6335                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6336                )
6337            };
6338            self.require_columns_for(
6339                table_name,
6340                crate::auth::ColumnOperation::Select,
6341                columns,
6342                effective_principal.as_ref(),
6343            )?;
6344            if !allow_table_security && security.table_has_security(table_name) {
6345                return Err(MongrelError::InvalidArgument(
6346                    "incremental aggregate is unsupported while RLS or column masks are active"
6347                        .into(),
6348                ));
6349            }
6350            let result = {
6351                let mut table = handle.lock();
6352                let authorization = if security.rls_enabled(table_name) {
6353                    Some(crate::security::CandidateAuthorization {
6354                        table: table_name,
6355                        security: &security,
6356                        principal: effective_principal
6357                            .as_ref()
6358                            .ok_or(MongrelError::AuthRequired)?,
6359                    })
6360                } else {
6361                    None
6362                };
6363                aggregate(
6364                    &mut table,
6365                    authorization.as_ref(),
6366                    effective_principal.as_ref(),
6367                    security_version,
6368                )?
6369            };
6370            if self.catalog.read().security_version == security_version {
6371                return Ok(result);
6372            }
6373            if attempt + 1 == RETRIES {
6374                return Err(MongrelError::Conflict(
6375                    "security policy changed during aggregate read".into(),
6376                ));
6377            }
6378        }
6379        Err(MongrelError::Conflict(
6380            "aggregate authorization retry loop exhausted".into(),
6381        ))
6382    }
6383
6384    /// Scored-read authorization that evaluates RLS only for approximate
6385    /// candidates. This avoids a full-table policy scan on cache misses while
6386    /// preserving one table generation and security-version retry.
6387    pub fn with_authorized_scored_read_context<T, F>(
6388        &self,
6389        table_name: &str,
6390        principal: Option<&crate::auth::Principal>,
6391        catalog_bound: bool,
6392        authorization: Option<&ReadAuthorization>,
6393        context: Option<&crate::query::AiExecutionContext>,
6394        mut read: F,
6395    ) -> Result<T>
6396    where
6397        F: FnMut(
6398            &mut Table,
6399            Snapshot,
6400            Option<&crate::security::CandidateAuthorization<'_>>,
6401            Option<&crate::auth::Principal>,
6402        ) -> Result<T>,
6403    {
6404        self.with_authorized_scored_read_context_at(
6405            table_name,
6406            principal,
6407            catalog_bound,
6408            authorization,
6409            context,
6410            None,
6411            |table, snapshot, authorization, principal| {
6412                let mut table = table.clone();
6413                read(&mut table, snapshot, authorization, principal)
6414            },
6415        )
6416    }
6417
6418    #[allow(clippy::too_many_arguments)]
6419    pub fn with_authorized_scored_read_context_at<T, F>(
6420        &self,
6421        table_name: &str,
6422        principal: Option<&crate::auth::Principal>,
6423        catalog_bound: bool,
6424        authorization: Option<&ReadAuthorization>,
6425        context: Option<&crate::query::AiExecutionContext>,
6426        snapshot_override: Option<Snapshot>,
6427        read: F,
6428    ) -> Result<T>
6429    where
6430        F: FnMut(
6431            &Table,
6432            Snapshot,
6433            Option<&crate::security::CandidateAuthorization<'_>>,
6434            Option<&crate::auth::Principal>,
6435        ) -> Result<T>,
6436    {
6437        self.with_authorized_scored_read_context_at_stamped(
6438            table_name,
6439            principal,
6440            catalog_bound,
6441            authorization,
6442            context,
6443            snapshot_override,
6444            read,
6445        )
6446        .map(|(result, _)| result)
6447    }
6448
6449    #[allow(clippy::too_many_arguments)]
6450    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
6451        &self,
6452        table_name: &str,
6453        principal: Option<&crate::auth::Principal>,
6454        catalog_bound: bool,
6455        authorization: Option<&ReadAuthorization>,
6456        context: Option<&crate::query::AiExecutionContext>,
6457        snapshot_override: Option<Snapshot>,
6458        mut read: F,
6459    ) -> Result<(T, AuthorizedReadStamp)>
6460    where
6461        F: FnMut(
6462            &Table,
6463            Snapshot,
6464            Option<&crate::security::CandidateAuthorization<'_>>,
6465            Option<&crate::auth::Principal>,
6466        ) -> Result<T>,
6467    {
6468        if principal.is_none() && self.principal.read().is_some() {
6469            self.refresh_principal()?;
6470        }
6471        const RETRIES: usize = 3;
6472        let handle = self.table(table_name)?;
6473        for attempt in 0..RETRIES {
6474            if let Some(context) = context {
6475                context.checkpoint()?;
6476            }
6477            crate::trace::QueryTrace::record(|trace| {
6478                trace.authorization_retries = attempt;
6479            });
6480            let (security, security_version, effective_principal) = {
6481                let catalog = self.catalog.read();
6482                (
6483                    catalog.security.clone(),
6484                    catalog.security_version,
6485                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6486                )
6487            };
6488            if let Some(authorization) = authorization {
6489                for permission in &authorization.permissions {
6490                    self.require_for(effective_principal.as_ref(), permission)?;
6491                }
6492                self.require_columns_for(
6493                    table_name,
6494                    authorization.operation,
6495                    &authorization.columns,
6496                    effective_principal.as_ref(),
6497                )?;
6498            }
6499            let result = {
6500                let (table, snapshot, _snapshot_guard, _run_pins) =
6501                    self.scored_read_generation(&handle, context, snapshot_override)?;
6502                let candidate_authorization = if security.rls_enabled(table_name) {
6503                    Some(crate::security::CandidateAuthorization {
6504                        table: table_name,
6505                        security: &security,
6506                        principal: effective_principal
6507                            .as_ref()
6508                            .ok_or(MongrelError::AuthRequired)?,
6509                    })
6510                } else {
6511                    None
6512                };
6513                let stamp = AuthorizedReadStamp {
6514                    table_id: table.table_id(),
6515                    schema_id: table.schema().schema_id,
6516                    data_generation: table.data_generation(),
6517                    security_version,
6518                    snapshot,
6519                };
6520                let result = read(
6521                    table.as_ref(),
6522                    snapshot,
6523                    candidate_authorization.as_ref(),
6524                    effective_principal.as_ref(),
6525                )?;
6526                (result, stamp)
6527            };
6528            if let Some(context) = context {
6529                context.checkpoint()?;
6530            }
6531            if self.catalog.read().security_version == security_version {
6532                return Ok(result);
6533            }
6534            if attempt + 1 == RETRIES {
6535                return Err(MongrelError::Conflict(
6536                    "security policy changed during scored read".into(),
6537                ));
6538            }
6539        }
6540        Err(MongrelError::Conflict(
6541            "scored-read authorization retry loop exhausted".into(),
6542        ))
6543    }
6544
6545    fn scored_read_generation(
6546        &self,
6547        handle: &TableHandle,
6548        context: Option<&crate::query::AiExecutionContext>,
6549        snapshot_override: Option<Snapshot>,
6550    ) -> Result<(
6551        Arc<TableReadGeneration>,
6552        Snapshot,
6553        crate::retention::OwnedSnapshotGuard,
6554        RunPins,
6555    )> {
6556        let mut table = if let Some(context) = context {
6557            loop {
6558                context.checkpoint()?;
6559                let wait = context
6560                    .remaining_duration()
6561                    .unwrap_or(std::time::Duration::from_millis(5))
6562                    .min(std::time::Duration::from_millis(5));
6563                if let Some(table) = handle.try_lock_for(wait) {
6564                    break table;
6565                }
6566            }
6567        } else {
6568            handle.lock()
6569        };
6570        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
6571            self.snapshot_at_owned(snapshot.epoch)?
6572        } else {
6573            let snapshot = table.snapshot();
6574            let guard = self.snapshots.register_owned(snapshot.epoch);
6575            (snapshot, guard)
6576        };
6577        let table_id = table.table_id();
6578        let run_keys: Vec<_> = table
6579            .active_run_ids()
6580            .map(|run_id| (table_id, run_id))
6581            .collect();
6582        let generation = handle
6583            .generation_metrics
6584            .activate(table.clone_read_generation()?);
6585        let run_pins = self.pin_runs(&run_keys);
6586        Ok((generation, snapshot, snapshot_guard, run_pins))
6587    }
6588
6589    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
6590        let mut pins = self.backup_pins.lock();
6591        for run in runs {
6592            *pins.entry(*run).or_insert(0) += 1;
6593        }
6594        drop(pins);
6595        RunPins {
6596            pins: Arc::clone(&self.backup_pins),
6597            runs: runs.to_vec(),
6598        }
6599    }
6600
6601    /// Execute a native conjunctive read with the database principal's row
6602    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
6603    /// policy-unaware; language bindings must use this boundary for reads.
6604    pub fn query_for_current_principal(
6605        &self,
6606        table_name: &str,
6607        query: &crate::query::Query,
6608        projection: Option<&[u16]>,
6609    ) -> Result<Vec<crate::memtable::Row>> {
6610        let condition_columns = crate::query::condition_columns(&query.conditions);
6611        let catalog_bound = self
6612            .principal
6613            .read()
6614            .as_ref()
6615            .is_some_and(|principal| principal.user_id != 0);
6616        self.with_authorized_read(
6617            table_name,
6618            None,
6619            catalog_bound,
6620            |table, snapshot, allowed, principal| {
6621                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6622                self.require_columns_for(
6623                    table_name,
6624                    crate::auth::ColumnOperation::Select,
6625                    &condition_columns,
6626                    principal,
6627                )?;
6628                if let Some(projection) = projection {
6629                    self.require_columns_for(
6630                        table_name,
6631                        crate::auth::ColumnOperation::Select,
6632                        projection,
6633                        principal,
6634                    )?;
6635                }
6636                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
6637                let projection =
6638                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6639                for row in &mut rows {
6640                    row.columns.retain(|column, _| {
6641                        allowed_columns.contains(column)
6642                            && projection
6643                                .as_ref()
6644                                .is_none_or(|projection| projection.contains(column))
6645                    });
6646                }
6647                self.secure_rows_for(table_name, rows, principal)
6648            },
6649        )
6650    }
6651
6652    /// Execute a secured native read with cooperative cancellation across
6653    /// authorization, candidate generation, materialization, masking, and
6654    /// projection.
6655    pub fn query_for_current_principal_controlled(
6656        &self,
6657        table_name: &str,
6658        query: &crate::query::Query,
6659        projection: Option<&[u16]>,
6660        control: &crate::ExecutionControl,
6661    ) -> Result<Vec<crate::memtable::Row>> {
6662        let catalog_bound = self
6663            .principal
6664            .read()
6665            .as_ref()
6666            .is_some_and(|principal| principal.user_id != 0);
6667        self.query_for_principal_controlled(
6668            table_name,
6669            query,
6670            projection,
6671            None,
6672            catalog_bound,
6673            control,
6674        )
6675    }
6676
6677    /// Execute a secured native read as an explicitly validated principal.
6678    ///
6679    /// Protocol adapters use this after resolving a session-bound identity.
6680    pub fn query_as_principal_controlled(
6681        &self,
6682        table_name: &str,
6683        query: &crate::query::Query,
6684        projection: Option<&[u16]>,
6685        principal: Option<&crate::auth::Principal>,
6686        control: &crate::ExecutionControl,
6687    ) -> Result<Vec<crate::memtable::Row>> {
6688        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6689        self.query_for_principal_controlled(
6690            table_name,
6691            query,
6692            projection,
6693            principal,
6694            catalog_bound,
6695            control,
6696        )
6697    }
6698
6699    fn query_for_principal_controlled(
6700        &self,
6701        table_name: &str,
6702        query: &crate::query::Query,
6703        projection: Option<&[u16]>,
6704        principal: Option<&crate::auth::Principal>,
6705        catalog_bound: bool,
6706        control: &crate::ExecutionControl,
6707    ) -> Result<Vec<crate::memtable::Row>> {
6708        control.checkpoint()?;
6709        let context = crate::query::AiExecutionContext::with_control(
6710            control.clone(),
6711            usize::MAX,
6712            crate::query::MAX_FUSED_CANDIDATES,
6713        );
6714        let condition_columns = crate::query::condition_columns(&query.conditions);
6715        self.with_authorized_read_context(
6716            table_name,
6717            principal,
6718            catalog_bound,
6719            None,
6720            Some(&context),
6721            None,
6722            |table, snapshot, allowed, principal| {
6723                control.checkpoint()?;
6724                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6725                self.require_columns_for(
6726                    table_name,
6727                    crate::auth::ColumnOperation::Select,
6728                    &condition_columns,
6729                    principal,
6730                )?;
6731                if let Some(projection) = projection {
6732                    self.require_columns_for(
6733                        table_name,
6734                        crate::auth::ColumnOperation::Select,
6735                        projection,
6736                        principal,
6737                    )?;
6738                }
6739                let rows =
6740                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
6741                let projection =
6742                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6743                let mut projected = Vec::with_capacity(rows.len());
6744                for (index, mut row) in rows.into_iter().enumerate() {
6745                    if index & 255 == 0 {
6746                        control.checkpoint()?;
6747                    }
6748                    row.columns.retain(|column, _| {
6749                        allowed_columns.contains(column)
6750                            && projection
6751                                .as_ref()
6752                                .is_none_or(|projection| projection.contains(column))
6753                    });
6754                    projected.push(row);
6755                }
6756                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
6757            },
6758        )
6759    }
6760
6761    /// Reservoir aggregate with column grants, RLS, masks, and security-version
6762    /// retry applied at the database boundary.
6763    pub fn approx_aggregate_for_current_principal(
6764        &self,
6765        table_name: &str,
6766        conditions: &[crate::query::Condition],
6767        column: Option<u16>,
6768        agg: crate::engine::ApproxAgg,
6769        z: f64,
6770    ) -> Result<Option<crate::engine::ApproxResult>> {
6771        if !z.is_finite() || z <= 0.0 {
6772            return Err(MongrelError::InvalidArgument(
6773                "z must be finite and > 0".into(),
6774            ));
6775        }
6776        let mut columns = crate::query::condition_columns(conditions);
6777        columns.extend(column);
6778        columns.sort_unstable();
6779        columns.dedup();
6780        self.with_authorized_aggregate_table(
6781            table_name,
6782            &columns,
6783            None,
6784            true,
6785            true,
6786            |table, authorization, _, _| {
6787                table.approx_aggregate_with_candidate_authorization(
6788                    conditions,
6789                    column,
6790                    agg,
6791                    z,
6792                    authorization,
6793                )
6794            },
6795        )
6796    }
6797
6798    /// Incremental aggregate over an append-only table. Active RLS or masks are
6799    /// rejected because the table-global delta cache cannot safely represent a
6800    /// secured row universe.
6801    pub fn incremental_aggregate_for_current_principal(
6802        &self,
6803        table_name: &str,
6804        conditions: &[crate::query::Condition],
6805        column: Option<u16>,
6806        agg: crate::engine::NativeAgg,
6807    ) -> Result<crate::engine::IncrementalAggResult> {
6808        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
6809    }
6810
6811    /// Incremental aggregate using an explicit request principal. A
6812    /// catalog-bound principal is re-resolved on every retry so live grants,
6813    /// revocations, RLS, and masks cannot reuse a stale cache entry.
6814    pub fn incremental_aggregate_for_principal(
6815        &self,
6816        table_name: &str,
6817        conditions: &[crate::query::Condition],
6818        column: Option<u16>,
6819        agg: crate::engine::NativeAgg,
6820        principal: Option<&crate::auth::Principal>,
6821        catalog_bound: bool,
6822    ) -> Result<crate::engine::IncrementalAggResult> {
6823        let mut columns = crate::query::condition_columns(conditions);
6824        columns.extend(column);
6825        columns.sort_unstable();
6826        columns.dedup();
6827        self.with_authorized_aggregate_table(
6828            table_name,
6829            &columns,
6830            principal,
6831            catalog_bound,
6832            false,
6833            |table, _, principal, security_version| {
6834                let cache_key = incremental_aggregate_cache_key(
6835                    table_name,
6836                    conditions,
6837                    column,
6838                    agg,
6839                    principal,
6840                    security_version,
6841                );
6842                table.aggregate_incremental(cache_key, conditions, column, agg)
6843            },
6844        )
6845    }
6846
6847    /// Read one row with the database principal's row policy, column grants,
6848    /// and masks applied.
6849    pub fn get_for_current_principal(
6850        &self,
6851        table_name: &str,
6852        row_id: RowId,
6853    ) -> Result<Option<crate::memtable::Row>> {
6854        self.with_authorized_read(
6855            table_name,
6856            None,
6857            true,
6858            |table, snapshot, allowed, principal| {
6859                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6860                let Some(row) = table.get(row_id, snapshot) else {
6861                    return Ok(None);
6862                };
6863                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
6864                    return Ok(None);
6865                }
6866                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6867                if let Some(row) = rows.first_mut() {
6868                    row.columns
6869                        .retain(|column, _| allowed_columns.contains(column));
6870                }
6871                Ok(rows.pop())
6872            },
6873        )
6874    }
6875
6876    /// Run exact ANN reranking over only rows authorized for this database
6877    /// handle. The embedding column still requires normal column access.
6878    pub fn ann_rerank_for_current_principal(
6879        &self,
6880        table_name: &str,
6881        request: &crate::query::AnnRerankRequest,
6882    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6883        self.with_authorized_scored_read_context_at(
6884            table_name,
6885            None,
6886            true,
6887            Some(&ReadAuthorization {
6888                operation: crate::auth::ColumnOperation::Select,
6889                columns: vec![request.column_id],
6890                permissions: Vec::new(),
6891            }),
6892            None,
6893            None,
6894            |table, snapshot, authorization, principal| {
6895                self.require_columns_for(
6896                    table_name,
6897                    crate::auth::ColumnOperation::Select,
6898                    &[request.column_id],
6899                    principal,
6900                )?;
6901                table.ann_rerank_at_with_candidate_authorization_on_generation(
6902                    request,
6903                    snapshot,
6904                    authorization,
6905                    None,
6906                )
6907            },
6908        )
6909    }
6910
6911    /// Run a hybrid scored search over only rows authorized for this database
6912    /// handle. Applies retriever column grants, RLS, and masks to the returned
6913    /// hits.
6914    pub fn search_for_current_principal(
6915        &self,
6916        table_name: &str,
6917        request: &crate::query::SearchRequest,
6918    ) -> Result<Vec<crate::query::SearchHit>> {
6919        let principal = self.principal_snapshot();
6920        self.search_for_principal_with_context(table_name, request, principal.as_ref(), None)
6921    }
6922
6923    /// Run a hybrid scored search as an explicitly validated principal.
6924    ///
6925    /// Protocol adapters use this after validating their session-bound
6926    /// identity. RLS, column grants, masks, cancellation, deadlines, and work
6927    /// limits remain enforced inside the storage authorization boundary.
6928    pub fn search_for_principal_with_context(
6929        &self,
6930        table_name: &str,
6931        request: &crate::query::SearchRequest,
6932        principal: Option<&crate::auth::Principal>,
6933        context: Option<&crate::query::AiExecutionContext>,
6934    ) -> Result<Vec<crate::query::SearchHit>> {
6935        let mut columns = crate::query::condition_columns(&request.must);
6936        for named in &request.retrievers {
6937            columns.push(named.retriever.column_id());
6938        }
6939        if let Some(crate::query::Rerank::ExactVector {
6940            embedding_column, ..
6941        }) = &request.rerank
6942        {
6943            columns.push(*embedding_column);
6944        }
6945        if let Some(proj) = &request.projection {
6946            columns.extend(proj);
6947        }
6948        columns.sort_unstable();
6949        columns.dedup();
6950        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6951        self.with_authorized_scored_read_context_at(
6952            table_name,
6953            principal,
6954            catalog_bound,
6955            Some(&ReadAuthorization {
6956                operation: crate::auth::ColumnOperation::Select,
6957                columns: columns.clone(),
6958                permissions: Vec::new(),
6959            }),
6960            context,
6961            None,
6962            |table, snapshot, authorization, principal| {
6963                self.require_columns_for(
6964                    table_name,
6965                    crate::auth::ColumnOperation::Select,
6966                    &columns,
6967                    principal,
6968                )?;
6969                let hits = table.search_at_with_candidate_authorization_on_generation(
6970                    request,
6971                    snapshot,
6972                    authorization,
6973                    context,
6974                )?;
6975                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6976                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
6977                for mut hit in hits {
6978                    let row = crate::memtable::Row {
6979                        row_id: hit.row_id,
6980                        committed_epoch: crate::Epoch(0),
6981                        columns: hit
6982                            .cells
6983                            .iter()
6984                            .cloned()
6985                            .collect::<std::collections::HashMap<u16, crate::Value>>(),
6986                        deleted: false,
6987                        commit_ts: None,
6988                    };
6989                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6990                    if let Some(mut row) = rows.pop() {
6991                        row.columns
6992                            .retain(|column, _| allowed_columns.contains(column));
6993                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
6994                        hit.cells.sort_by_key(|(column, _)| *column);
6995                        secured.push(hit);
6996                    }
6997                }
6998                Ok(secured)
6999            },
7000        )
7001    }
7002
7003    /// Embed `text` with the active semantic identity for `embedding_column` and
7004    /// run ANN retrieval, returning hits plus query provenance (P0.7).
7005    pub fn retrieve_text(
7006        &self,
7007        table: &str,
7008        embedding_column: u16,
7009        text: &str,
7010        search_options: crate::embedding::TextSearchOptions,
7011    ) -> Result<crate::embedding::TextRetrieveResult> {
7012        let principal = self.principal_snapshot();
7013        self.retrieve_text_for_principal(
7014            table,
7015            embedding_column,
7016            text,
7017            search_options,
7018            principal.as_ref(),
7019        )
7020    }
7021
7022    pub fn retrieve_text_for_principal(
7023        &self,
7024        table: &str,
7025        embedding_column: u16,
7026        text: &str,
7027        search_options: crate::embedding::TextSearchOptions,
7028        principal: Option<&crate::auth::Principal>,
7029    ) -> Result<crate::embedding::TextRetrieveResult> {
7030        if search_options.k == 0 {
7031            return Err(MongrelError::InvalidArgument(
7032                "retrieve_text k must be greater than zero".into(),
7033            ));
7034        }
7035        let catalog_bound = principal.is_some_and(|p| p.user_id != 0);
7036        let (semantic_identity, registry_generation, expected_dim) = {
7037            let handle = self.table(table)?;
7038            let mut g = handle.lock();
7039            g.ensure_indexes_complete()?;
7040            let schema = g.schema().clone();
7041            let column = schema
7042                .columns
7043                .iter()
7044                .find(|c| c.id == embedding_column)
7045                .ok_or_else(|| {
7046                    MongrelError::ColumnNotFound(format!(
7047                        "embedding column {embedding_column} not found on table {table}"
7048                    ))
7049                })?;
7050            let dim = match column.ty {
7051                crate::schema::TypeId::Embedding { dim } => dim,
7052                _ => {
7053                    return Err(MongrelError::InvalidArgument(format!(
7054                        "column {embedding_column} is not an embedding column"
7055                    )))
7056                }
7057            };
7058            if let Some(identity) = g
7059                .ann_index(embedding_column)
7060                .and_then(|a| a.semantic_identity().cloned())
7061            {
7062                let (generation, provider) = self
7063                    .embedding_providers
7064                    .resolve_by_semantic_identity(&identity)
7065                    .map_err(embedding_error)?;
7066                if provider.dimension() != dim {
7067                    return Err(embedding_error(
7068                        crate::embedding::EmbeddingError::DimensionMismatch {
7069                            expected: dim,
7070                            got: provider.dimension(),
7071                        },
7072                    ));
7073                }
7074                (identity, generation, dim)
7075            } else {
7076                let source = column.embedding_source.clone().ok_or_else(|| {
7077                    embedding_error(crate::embedding::EmbeddingError::NoActiveAnnIdentity(
7078                        embedding_column,
7079                    ))
7080                })?;
7081                let (generation, provider) = self
7082                    .embedding_providers
7083                    .resolve_with_generation(&source)
7084                    .map_err(embedding_error)?;
7085                let identity = provider.semantic_identity();
7086                if identity.dimension != dim {
7087                    return Err(embedding_error(
7088                        crate::embedding::EmbeddingError::DimensionMismatch {
7089                            expected: dim,
7090                            got: identity.dimension,
7091                        },
7092                    ));
7093                }
7094                (identity, generation, dim)
7095            }
7096        };
7097        let (_g, provider) = self
7098            .embedding_providers
7099            .resolve_by_semantic_identity(&semantic_identity)
7100            .map_err(embedding_error)?;
7101        let control = crate::ExecutionControl::new(None);
7102        let response = provider
7103            .embed(crate::embedding::EmbeddingRequest {
7104                texts: &[text],
7105                control: &control,
7106                trace_id: "retrieve-text",
7107            })
7108            .map_err(embedding_error)?;
7109        crate::embedding::validate_response(
7110            provider.as_ref(),
7111            1,
7112            expected_dim,
7113            crate::embedding::EmbeddingLimits::default(),
7114            &response.vectors,
7115        )
7116        .map_err(embedding_error)?;
7117        let query = response.vectors.into_iter().next().ok_or_else(|| {
7118            MongrelError::Other("embedding provider returned no query vector".into())
7119        })?;
7120        if provider.semantic_identity() != semantic_identity {
7121            return Err(embedding_error(
7122                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7123                    column_id: embedding_column,
7124                    expected: semantic_identity.fingerprint_sha256(),
7125                    got: provider.semantic_identity().fingerprint_sha256(),
7126                },
7127            ));
7128        }
7129        let retriever = crate::query::Retriever::Ann {
7130            column_id: embedding_column,
7131            query,
7132            k: search_options.k,
7133        };
7134        let hits = self.with_authorized_scored_read_context_at(
7135            table,
7136            principal,
7137            catalog_bound,
7138            Some(&ReadAuthorization {
7139                operation: crate::auth::ColumnOperation::Select,
7140                columns: vec![embedding_column],
7141                permissions: Vec::new(),
7142            }),
7143            None,
7144            None,
7145            |table_guard, snapshot, authorization, principal| {
7146                self.require_columns_for(
7147                    table,
7148                    crate::auth::ColumnOperation::Select,
7149                    &[embedding_column],
7150                    principal,
7151                )?;
7152                if let Some(ann) = table_guard.ann_index(embedding_column) {
7153                    if let Some(active) = ann.semantic_identity() {
7154                        if active != &semantic_identity {
7155                            return Err(embedding_error(
7156                                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7157                                    column_id: embedding_column,
7158                                    expected: semantic_identity.fingerprint_sha256(),
7159                                    got: active.fingerprint_sha256(),
7160                                },
7161                            ));
7162                        }
7163                    }
7164                }
7165                table_guard.retrieve_at_with_candidate_authorization_on_generation(
7166                    &retriever,
7167                    snapshot,
7168                    authorization,
7169                    None,
7170                )
7171            },
7172        )?;
7173        Ok(crate::embedding::TextRetrieveResult {
7174            hits,
7175            provenance: crate::embedding::TextRetrieveProvenance {
7176                semantic_identity,
7177                provider_registry_generation: registry_generation,
7178                query_source_fingerprint: Sha256::digest(text.as_bytes()).into(),
7179                embedding_column,
7180            },
7181        })
7182    }
7183
7184    /// Capture one table snapshot and the security version used to authorize it.
7185    /// The caller must validate the returned version before publishing results.
7186    pub fn authorized_read_snapshot(
7187        &self,
7188        table: &str,
7189        principal: Option<&crate::auth::Principal>,
7190    ) -> Result<AuthorizedReadSnapshot> {
7191        let (security, security_version, effective_principal) = {
7192            let catalog = self.catalog.read();
7193            (
7194                catalog.security.clone(),
7195                catalog.security_version,
7196                self.principal_for_authorized_read(&catalog, principal, false)?,
7197            )
7198        };
7199        let handle = self.table(table)?;
7200        let (table_snapshot, data_generation, allowed_row_ids) = {
7201            let table_handle = handle.lock();
7202            let table_snapshot = table_handle.snapshot();
7203            let data_generation = table_handle.data_generation();
7204            let allowed = self.allowed_row_ids_locked(
7205                table,
7206                &table_handle,
7207                table_snapshot,
7208                (&security, security_version),
7209                effective_principal.as_ref(),
7210                None,
7211            )?;
7212            (
7213                table_snapshot,
7214                data_generation,
7215                allowed.map(|allowed| (*allowed).clone()),
7216            )
7217        };
7218        Ok(AuthorizedReadSnapshot {
7219            table: table.to_string(),
7220            table_snapshot,
7221            data_generation,
7222            security_version,
7223            allowed_row_ids,
7224        })
7225    }
7226
7227    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
7228        if self.catalog.read().security_version != snapshot.security_version {
7229            return false;
7230        }
7231        self.table(&snapshot.table)
7232            .ok()
7233            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
7234    }
7235
7236    pub fn rls_cache_stats(&self) -> RlsCacheStats {
7237        self.rls_cache.lock().stats()
7238    }
7239
7240    /// Read visible rows with column authorization, RLS, and masks applied.
7241    pub fn rows_for(
7242        &self,
7243        table: &str,
7244        principal: Option<&crate::auth::Principal>,
7245    ) -> Result<Vec<crate::memtable::Row>> {
7246        if principal.is_none() && self.principal.read().is_some() {
7247            self.refresh_principal()?;
7248        }
7249        let allowed = self.select_column_ids_for(table, principal)?;
7250        let handle = self.table(table)?;
7251        let rows = {
7252            let table = handle.lock();
7253            table.visible_rows(table.snapshot())?
7254        };
7255        let mut rows = self.secure_rows_for(table, rows, principal)?;
7256        for row in &mut rows {
7257            row.columns.retain(|column, _| allowed.contains(column));
7258        }
7259        Ok(rows)
7260    }
7261
7262    /// Historical rows use the current principal and security catalog against
7263    /// the row values visible at the requested snapshot.
7264    pub fn rows_at_epoch_for_current_principal(
7265        &self,
7266        table_name: &str,
7267        snapshot: Snapshot,
7268    ) -> Result<Vec<crate::memtable::Row>> {
7269        self.with_authorized_read_context(
7270            table_name,
7271            None,
7272            true,
7273            Some(&ReadAuthorization {
7274                operation: crate::auth::ColumnOperation::Select,
7275                columns: Vec::new(),
7276                permissions: Vec::new(),
7277            }),
7278            None,
7279            Some(snapshot),
7280            |table, snapshot, allowed, principal| {
7281                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
7282                let mut rows = table.visible_rows(snapshot)?;
7283                if let Some(allowed) = allowed {
7284                    rows.retain(|row| allowed.contains(&row.row_id));
7285                }
7286                rows = self.secure_rows_for(table_name, rows, principal)?;
7287                for row in &mut rows {
7288                    row.columns
7289                        .retain(|column, _| allowed_columns.contains(column));
7290                }
7291                Ok(rows)
7292            },
7293        )
7294    }
7295
7296    /// Count rows visible to a principal without bypassing RLS.
7297    pub fn count_for(
7298        &self,
7299        table: &str,
7300        principal: Option<&crate::auth::Principal>,
7301    ) -> Result<u64> {
7302        if principal.is_none() && self.principal.read().is_some() {
7303            self.refresh_principal()?;
7304        }
7305        self.select_column_ids_for(table, principal)?;
7306        if self.security_active_for(table) {
7307            Ok(self.rows_for(table, principal)?.len() as u64)
7308        } else {
7309            Ok(self.table(table)?.lock().count())
7310        }
7311    }
7312
7313    /// Authorize and write one native-API row for an explicit principal.
7314    pub fn put_for(
7315        &self,
7316        table: &str,
7317        mut cells: Vec<(u16, crate::memtable::Value)>,
7318        principal: Option<&crate::auth::Principal>,
7319    ) -> Result<RowId> {
7320        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
7321        self.require_columns_for(
7322            table,
7323            crate::auth::ColumnOperation::Insert,
7324            &columns,
7325            principal,
7326        )?;
7327        let handle = self.table(table)?;
7328        let mut table_handle = handle.lock();
7329        table_handle.fill_auto_inc(&mut cells)?;
7330        table_handle.apply_defaults(&mut cells)?;
7331        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
7332        row.columns.extend(cells.iter().cloned());
7333        self.check_row_policy_for(
7334            table,
7335            crate::security::PolicyCommand::Insert,
7336            &row,
7337            true,
7338            principal,
7339        )?;
7340        table_handle.put(cells)
7341    }
7342
7343    pub fn check_row_policy_for(
7344        &self,
7345        table: &str,
7346        command: crate::security::PolicyCommand,
7347        row: &crate::memtable::Row,
7348        check_new: bool,
7349        principal: Option<&crate::auth::Principal>,
7350    ) -> Result<()> {
7351        let security = self.catalog.read().security.clone();
7352        if !security.rls_enabled(table) {
7353            return Ok(());
7354        }
7355        let cached = self.principal.read().clone();
7356        let principal = principal
7357            .or(cached.as_ref())
7358            .ok_or(MongrelError::AuthRequired)?;
7359        if security.row_allowed(table, command, row, principal, check_new) {
7360            return Ok(());
7361        }
7362        let required = match command {
7363            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
7364                table: table.to_string(),
7365            },
7366            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
7367                table: table.to_string(),
7368            },
7369            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
7370                table: table.to_string(),
7371            },
7372            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
7373                crate::auth::Permission::Delete {
7374                    table: table.to_string(),
7375                }
7376            }
7377        };
7378        Err(MongrelError::PermissionDenied {
7379            required,
7380            principal: principal.username.clone(),
7381        })
7382    }
7383
7384    /// Durably create or replace a materialized-view definition after its
7385    /// physical table has been populated.
7386    pub fn set_materialized_view(
7387        &self,
7388        definition: crate::catalog::MaterializedViewEntry,
7389    ) -> Result<()> {
7390        self.set_materialized_view_with_epoch(definition)
7391            .map(|_| ())
7392    }
7393
7394    /// Durably create or replace a materialized-view definition and return its epoch.
7395    pub fn set_materialized_view_with_epoch(
7396        &self,
7397        definition: crate::catalog::MaterializedViewEntry,
7398    ) -> Result<Epoch> {
7399        use crate::wal::DdlOp;
7400        use std::sync::atomic::Ordering;
7401
7402        let command = crate::catalog_cmds::CatalogCommand::CreateMaterializedView {
7403            definition: definition.clone(),
7404        };
7405        self.require(&crate::catalog_cmds::required_permission(&command))?;
7406        if self.poisoned.load(Ordering::Relaxed) {
7407            return Err(MongrelError::Other(
7408                "database poisoned by fsync error".into(),
7409            ));
7410        }
7411        if definition.name.is_empty() || definition.query.trim().is_empty() {
7412            return Err(MongrelError::InvalidArgument(
7413                "materialized view name and query must not be empty".into(),
7414            ));
7415        }
7416        // S1A-004: admit the materialized-view replacement as one core
7417        // operation.
7418        let _operation = self.admit_operation()?;
7419        let _ddl = self.ddl_lock.lock();
7420        let _security_write = self.security_write()?;
7421        self.require(&crate::catalog_cmds::required_permission(&command))?;
7422        // S1F-001: the backing-table check validates pure against the current
7423        // catalog first so it surfaces before an epoch is consumed, exactly
7424        // like the legacy pre-bump lookup.
7425        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7426        let table_id = self
7427            .catalog
7428            .read()
7429            .live(&definition.name)
7430            .ok_or_else(|| {
7431                MongrelError::NotFound(format!(
7432                    "materialized view table {:?} not found",
7433                    definition.name
7434                ))
7435            })?
7436            .table_id;
7437        let definition_json = DdlOp::encode_materialized_view(&definition)?;
7438        let _commit = self.commit_lock.lock();
7439        let epoch = self.epoch.bump_assigned();
7440        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7441        let txn_id = self.alloc_txn_id()?;
7442        let mut next_catalog = self.catalog.read().clone();
7443        self.apply_catalog_command_to(&mut next_catalog, command)?;
7444        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
7445        let commit_seq = {
7446            let mut wal = self.shared_wal.lock();
7447            let append: Result<u64> = (|| {
7448                wal.append(
7449                    txn_id,
7450                    table_id,
7451                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
7452                        name: definition.name.clone(),
7453                        definition_json,
7454                    }),
7455                )?;
7456                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
7457                wal.append_commit(txn_id, epoch, &[])
7458            })();
7459            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
7460        };
7461        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
7462
7463        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
7464        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
7465        Ok(epoch)
7466    }
7467
7468    /// The filesystem root this database was opened/created at.
7469    pub fn root(&self) -> &Path {
7470        self.durable_root.canonical_path()
7471    }
7472
7473    /// Open a descriptor-pinned view of this database root for durable
7474    /// extension state such as server idempotency receipts.
7475    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
7476        Arc::clone(&self.durable_root)
7477    }
7478
7479    /// Domain-separated authentication key for server idempotency state.
7480    /// Encrypted databases derive it from the in-memory KEK. Plain databases
7481    /// return `None`; their server persists a random key under the pinned root.
7482    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
7483        self.kek
7484            .as_deref()
7485            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
7486    }
7487
7488    pub fn is_read_only_replica(&self) -> bool {
7489        self.read_only
7490    }
7491
7492    /// Reject reads whose backing state may require WAL recovery after a
7493    /// post-commit publication failure. Ordinary table/catalog state is made
7494    /// coherent before poison; file-backed external modules use this gate.
7495    pub fn ensure_consistent_read(&self) -> Result<()> {
7496        self.ensure_owner_process()?;
7497        if self.poisoned.load(Ordering::Relaxed) {
7498            return Err(MongrelError::Other(
7499                "database poisoned by post-commit failure; reopen required".into(),
7500            ));
7501        }
7502        Ok(())
7503    }
7504
7505    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
7506        self.replication_wal_retention_segments
7507            .store(segments, std::sync::atomic::Ordering::Relaxed);
7508    }
7509
7510    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
7511    /// direct table commits, compaction, and WAL append are quiesced while the
7512    /// file image is read. WAL records newer than manifests remain sufficient
7513    /// for recovery, so no flush or compaction is required.
7514    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
7515        let admin = crate::auth::Permission::Admin;
7516        self.require(&admin)?;
7517        // S1A-004: admit the bootstrap capture as one core operation.
7518        let _operation = self.admit_operation()?;
7519        let operation_principal = self.principal_snapshot();
7520        let _barrier = self.replication_barrier.write();
7521        let _ddl = self.ddl_lock.lock();
7522        let _security = self.security_coordinator.gate.read();
7523        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7524        let mut handles: Vec<_> = self
7525            .tables
7526            .read()
7527            .iter()
7528            .map(|(id, handle)| (*id, handle.clone()))
7529            .collect();
7530        handles.sort_by_key(|(id, _)| *id);
7531        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7532        let _commit = self.commit_lock.lock();
7533        let mut wal = self.shared_wal.lock();
7534        wal.group_sync()?;
7535        let io_root = self.durable_root.io_path()?;
7536        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7537        let records = crate::wal::SharedWal::replay_with_dek(&io_root, wal_dek.as_ref())?;
7538        let epoch = records
7539            .iter()
7540            .filter_map(|record| match &record.op {
7541                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
7542                _ => None,
7543            })
7544            .max()
7545            .unwrap_or(0)
7546            .max(self.epoch.committed().0);
7547        let files = crate::replication::capture_files(&io_root)?;
7548        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7549        drop(wal);
7550        Ok(crate::replication::ReplicationSnapshot::new(
7551            source_id, epoch, files,
7552        ))
7553    }
7554
7555    /// Create an online, directly-openable backup at `destination`.
7556    ///
7557    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
7558    /// mutable metadata, and pins the exact immutable runs named by the copied
7559    /// manifests. Writers resume while those runs stream into a sibling staging
7560    /// directory. A checksummed backup manifest is written last, then the stage
7561    /// is atomically renamed into place.
7562    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
7563        let control = crate::ExecutionControl::new(None);
7564        self.hot_backup_controlled(destination, &control, || true)
7565    }
7566
7567    pub(crate) fn hot_backup_to_durable_child(
7568        &self,
7569        parent: &crate::durable_file::DurableRoot,
7570        child: &Path,
7571        control: &crate::ExecutionControl,
7572    ) -> Result<crate::backup::BackupReport> {
7573        let mut components = child.components();
7574        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
7575            || components.next().is_some()
7576        {
7577            return Err(MongrelError::InvalidArgument(
7578                "durable backup child must be one relative path component".into(),
7579            ));
7580        }
7581        let destination_name = child.file_name().ok_or_else(|| {
7582            MongrelError::InvalidArgument("durable backup child has no filename".into())
7583        })?;
7584        let source_root = self.durable_root.io_path()?;
7585        let prepared = prepare_backup_destination_in(&source_root, parent, destination_name)?;
7586        self.hot_backup_prepared(prepared, control, || true)
7587    }
7588
7589    /// Build a backup cooperatively, then invoke `before_publish` immediately
7590    /// before the staging directory is atomically renamed into place.
7591    #[doc(hidden)]
7592    pub fn hot_backup_controlled<F>(
7593        &self,
7594        destination: impl AsRef<Path>,
7595        control: &crate::ExecutionControl,
7596        before_publish: F,
7597    ) -> Result<crate::backup::BackupReport>
7598    where
7599        F: FnOnce() -> bool,
7600    {
7601        let source_root = self.durable_root.io_path()?;
7602        let prepared = prepare_backup_destination(&source_root, destination.as_ref())?;
7603        self.hot_backup_prepared(prepared, control, before_publish)
7604    }
7605
7606    fn hot_backup_prepared<F>(
7607        &self,
7608        mut prepared: PreparedBackupDestination,
7609        control: &crate::ExecutionControl,
7610        before_publish: F,
7611    ) -> Result<crate::backup::BackupReport>
7612    where
7613        F: FnOnce() -> bool,
7614    {
7615        let admin = crate::auth::Permission::Admin;
7616        self.require(&admin)?;
7617        // S1A-004: admit the backup as one core operation; `shutdown()` waits
7618        // for it to drain instead of closing the core mid-copy.
7619        let _operation = self.admit_operation()?;
7620        let operation_principal = self.principal_snapshot();
7621        control.checkpoint()?;
7622        let destination = prepared.destination_path.clone();
7623        let mut before_publish = Some(before_publish);
7624
7625        let outcome = (|| {
7626            control.checkpoint()?;
7627            let barrier = self.replication_barrier.write();
7628            let ddl = self.ddl_lock.lock();
7629            let security = self.security_coordinator.gate.read();
7630            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7631            let mut handles: Vec<_> = self
7632                .tables
7633                .read()
7634                .iter()
7635                .map(|(id, handle)| (*id, handle.clone()))
7636                .collect();
7637            handles.sort_by_key(|(id, _)| *id);
7638            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7639            let commit = self.commit_lock.lock();
7640            let mut wal = self.shared_wal.lock();
7641            wal.group_sync()?;
7642            let epoch = self.epoch.committed().0;
7643            let boundary_unix_nanos = current_unix_nanos();
7644            let source_root = self.durable_root.io_path()?;
7645
7646            let pin_nonce = std::time::SystemTime::now()
7647                .duration_since(std::time::UNIX_EPOCH)
7648                .unwrap_or_default()
7649                .as_nanos();
7650            let file_pin_root = source_root
7651                .join(META_DIR)
7652                .join("backup-pins")
7653                .join(format!("{}-{pin_nonce}", std::process::id()));
7654            std::fs::create_dir_all(&file_pin_root)?;
7655            let _file_pins = BackupFilePins {
7656                root: file_pin_root.clone(),
7657            };
7658            let mut run_files = Vec::new();
7659            for (index, (table_id, _)) in handles.iter().enumerate() {
7660                if index % 256 == 0 {
7661                    control.checkpoint()?;
7662                }
7663                let table = &table_guards[index];
7664                for (run_index, run) in table.run_refs().iter().enumerate() {
7665                    if run_index % 256 == 0 {
7666                        control.checkpoint()?;
7667                    }
7668                    let source = table.run_path(run.run_id as u64);
7669                    let relative = Path::new(TABLES_DIR)
7670                        .join(table_id.to_string())
7671                        .join(crate::engine::RUNS_DIR)
7672                        .join(format!("r-{}.sr", run.run_id));
7673                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
7674                    if std::fs::hard_link(&source, &pinned).is_err() {
7675                        crate::backup::copy_file_synced(&source, &pinned)?;
7676                    }
7677                    run_files.push(((*table_id, run.run_id), pinned, relative));
7678                }
7679            }
7680            crate::durable_file::sync_directory(&file_pin_root)?;
7681            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
7682            {
7683                let mut pins = self.backup_pins.lock();
7684                for key in &run_keys {
7685                    *pins.entry(*key).or_insert(0) += 1;
7686                }
7687            }
7688            let _run_pins = RunPins {
7689                pins: Arc::clone(&self.backup_pins),
7690                runs: run_keys,
7691            };
7692            // S1C-004: mirror the backup boundary into every table's unified
7693            // pin registry (PinSource::BackupPitr covers PITR base captures,
7694            // which route through this same path). Version GC must not
7695            // reclaim the boundary versions while their runs stream into the
7696            // stage, and diagnostics must expose the pin. The guards drop
7697            // with `_run_pins` when the backup finishes or fails.
7698            let _registry_pins: Vec<crate::retention::PinGuard> = table_guards
7699                .iter()
7700                .map(|table| {
7701                    table
7702                        .pin_registry()
7703                        .pin(crate::retention::PinSource::BackupPitr, Epoch(epoch))
7704                })
7705                .collect();
7706            let deferred: HashSet<_> = run_files
7707                .iter()
7708                .map(|(_, _, relative)| relative.clone())
7709                .collect();
7710            let mut copied = Vec::new();
7711            copy_backup_boundary(
7712                &source_root,
7713                prepared.stage.as_deref().ok_or_else(|| {
7714                    MongrelError::Other("backup staging root was already released".into())
7715                })?,
7716                &deferred,
7717                &mut copied,
7718                Some(control),
7719            )?;
7720
7721            drop(wal);
7722            drop(commit);
7723            drop(table_guards);
7724            drop(security);
7725            drop(ddl);
7726            drop(barrier);
7727
7728            if let Some(hook) = self.backup_hook.lock().as_ref() {
7729                hook();
7730            }
7731            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
7732                if index % 256 == 0 {
7733                    control.checkpoint()?;
7734                }
7735                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
7736                prepared
7737                    .stage
7738                    .as_deref()
7739                    .ok_or_else(|| {
7740                        MongrelError::Other("backup staging root was already released".into())
7741                    })?
7742                    .copy_new_from(&relative, &mut source)?;
7743                copied.push(relative);
7744            }
7745
7746            let manifest = crate::backup::BackupManifest::create_controlled_durable(
7747                prepared.stage.as_deref().ok_or_else(|| {
7748                    MongrelError::Other("backup staging root was already released".into())
7749                })?,
7750                epoch,
7751                &copied,
7752                control,
7753            )?;
7754            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
7755                MongrelError::Other("backup staging root was already released".into())
7756            })?)?;
7757            control.checkpoint()?;
7758            let publish = before_publish.take().ok_or_else(|| {
7759                MongrelError::Other("backup publication callback already consumed".into())
7760            })?;
7761            if !publish() {
7762                return Err(MongrelError::Cancelled);
7763            }
7764            let final_security = self.security_coordinator.gate.read();
7765            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7766            // Windows pins directories without delete sharing. Release the
7767            // stage handle before renaming that directory, while the parent
7768            // remains descriptor-pinned for the no-replace publication.
7769            drop(prepared.stage.take().ok_or_else(|| {
7770                MongrelError::Other("backup staging root was already released".into())
7771            })?);
7772            let published = std::cell::Cell::new(false);
7773            if let Err(error) = prepared.parent.rename_directory_new_with_after(
7774                Path::new(&prepared.stage_name),
7775                &prepared.parent,
7776                Path::new(&prepared.destination_name),
7777                || published.set(true),
7778            ) {
7779                if published.get() {
7780                    return Err(MongrelError::CommitOutcomeUnknown {
7781                        epoch,
7782                        message: format!("backup publication was not durable: {error}"),
7783                    });
7784                }
7785                return Err(error.into());
7786            }
7787            drop(final_security);
7788            Ok(crate::backup::BackupReport {
7789                destination,
7790                epoch,
7791                boundary_unix_nanos,
7792                files: manifest.files.len(),
7793                bytes: manifest.total_bytes(),
7794            })
7795        })();
7796
7797        if outcome.is_err() {
7798            drop(prepared.stage.take());
7799            let _ = prepared
7800                .parent
7801                .remove_directory_all(Path::new(&prepared.stage_name));
7802        }
7803        outcome
7804    }
7805
7806    /// Return complete committed transactions after `since_epoch`. A gap or a
7807    /// transaction backed by a spilled run requires a fresh bootstrap image.
7808    pub fn replication_batch_since(
7809        &self,
7810        since_epoch: u64,
7811    ) -> Result<crate::replication::ReplicationBatch> {
7812        use crate::wal::Op;
7813
7814        let admin = crate::auth::Permission::Admin;
7815        self.require(&admin)?;
7816        // S1A-004: admit the batch extraction as one core operation.
7817        let _operation = self.admit_operation()?;
7818        let operation_principal = self.principal_snapshot();
7819
7820        let mut wal = self.shared_wal.lock();
7821        wal.group_sync()?;
7822        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7823        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
7824        drop(wal);
7825
7826        let commits: HashMap<u64, u64> = records
7827            .iter()
7828            .filter_map(|record| match &record.op {
7829                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
7830                _ => None,
7831            })
7832            .collect();
7833        let earliest_epoch = commits.values().copied().min();
7834        let current_epoch = commits
7835            .values()
7836            .copied()
7837            .max()
7838            .unwrap_or(0)
7839            .max(self.epoch.committed().0);
7840        let selected: HashSet<u64> = commits
7841            .iter()
7842            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
7843            .collect();
7844        let retention_gap = since_epoch < current_epoch
7845            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
7846        let spilled = records.iter().any(|record| {
7847            selected.contains(&record.txn_id)
7848                && matches!(
7849                    &record.op,
7850                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
7851                )
7852        });
7853        let records = records
7854            .into_iter()
7855            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
7856            .filter(|record| selected.contains(&record.txn_id))
7857            .collect();
7858        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7859        let batch = crate::replication::ReplicationBatch::complete_for_source(
7860            source_id,
7861            since_epoch,
7862            current_epoch,
7863            earliest_epoch,
7864            retention_gap,
7865            spilled,
7866            records,
7867        )?;
7868        if let Some(hook) = self.replication_hook.lock().as_ref() {
7869            hook();
7870        }
7871        let _security = self.security_coordinator.gate.read();
7872        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7873        Ok(batch)
7874    }
7875
7876    /// Durably append a leader batch to a follower's local WAL and checkpoint
7877    /// its catalog metadata. Security changes apply to this live handle before
7878    /// success returns. The caller must reopen to mount new table state.
7879    pub fn append_replication_batch(
7880        &self,
7881        batch: &crate::replication::ReplicationBatch,
7882    ) -> Result<u64> {
7883        use crate::wal::Op;
7884
7885        if !self.read_only {
7886            return Err(MongrelError::InvalidArgument(
7887                "replication batches may only target a marked replica".into(),
7888            ));
7889        }
7890        // S1A-004: admit the follow-apply as one core operation.
7891        let _operation = self.admit_operation()?;
7892        let current = crate::replication::replica_epoch(&self.root)?;
7893        if batch.is_source_bound() {
7894            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
7895            if batch.source_id != source_id {
7896                return Err(MongrelError::Conflict(
7897                    "replication batch source does not match follower binding".into(),
7898                ));
7899            }
7900        }
7901        if batch.requires_snapshot {
7902            return Err(MongrelError::Conflict(
7903                "replication snapshot required for this batch".into(),
7904            ));
7905        }
7906        batch.validate_proof()?;
7907        if batch.from_epoch != current {
7908            if batch.from_epoch < current && batch.current_epoch == current {
7909                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7910                let _wal = self.shared_wal.lock();
7911                let existing: HashSet<(u64, u64)> =
7912                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7913                        .into_iter()
7914                        .filter_map(|record| match record.op {
7915                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7916                            _ => None,
7917                        })
7918                        .collect();
7919                let already_applied = batch.records.iter().all(|record| match &record.op {
7920                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
7921                    _ => true,
7922                });
7923                if already_applied {
7924                    return Ok(current);
7925                }
7926            }
7927            return Err(MongrelError::Conflict(format!(
7928                "replication batch starts at epoch {}, follower is at epoch {current}",
7929                batch.from_epoch
7930            )));
7931        }
7932        if batch.current_epoch < current {
7933            return Err(MongrelError::InvalidArgument(format!(
7934                "replication batch current epoch {} precedes follower epoch {current}",
7935                batch.current_epoch
7936            )));
7937        }
7938        let records = &batch.records;
7939        let mut commits = HashMap::new();
7940        let mut commit_epochs = HashSet::new();
7941        let mut commit_timestamps = HashMap::new();
7942        for record in records {
7943            match &record.op {
7944                Op::TxnCommit { epoch, added_runs } => {
7945                    if !added_runs.is_empty() {
7946                        return Err(MongrelError::Conflict(
7947                            "replication snapshot required for spilled-run transaction".into(),
7948                        ));
7949                    }
7950                    if commits.insert(record.txn_id, *epoch).is_some() {
7951                        return Err(MongrelError::InvalidArgument(format!(
7952                            "duplicate commit for replication transaction {}",
7953                            record.txn_id
7954                        )));
7955                    }
7956                    if *epoch <= current || *epoch > batch.current_epoch {
7957                        return Err(MongrelError::InvalidArgument(format!(
7958                            "replication commit epoch {epoch} is outside ({current}, {}]",
7959                            batch.current_epoch
7960                        )));
7961                    }
7962                    if !commit_epochs.insert(*epoch) {
7963                        return Err(MongrelError::InvalidArgument(format!(
7964                            "duplicate replication commit epoch {epoch}"
7965                        )));
7966                    }
7967                }
7968                Op::CommitTimestamp { unix_nanos } => {
7969                    commit_timestamps.insert(record.txn_id, *unix_nanos);
7970                }
7971                _ => {}
7972            }
7973        }
7974        for record in records {
7975            if record.txn_id == crate::wal::SYSTEM_TXN_ID
7976                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
7977            {
7978                return Err(MongrelError::InvalidArgument(
7979                    "replication batch contains a non-committed record".into(),
7980                ));
7981            }
7982            if !commits.contains_key(&record.txn_id) {
7983                return Err(MongrelError::InvalidArgument(format!(
7984                    "incomplete replication transaction {}",
7985                    record.txn_id
7986                )));
7987            }
7988        }
7989        let target_epoch = commits
7990            .values()
7991            .copied()
7992            .filter(|epoch| *epoch > current)
7993            .max()
7994            .unwrap_or(current);
7995        if target_epoch != batch.current_epoch {
7996            return Err(MongrelError::InvalidArgument(format!(
7997                "replication batch ends at epoch {target_epoch}, expected {}",
7998                batch.current_epoch
7999            )));
8000        }
8001        let mut selected: HashSet<u64> = commits
8002            .iter()
8003            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
8004            .collect();
8005        if selected.is_empty() {
8006            return Ok(current);
8007        }
8008        let mut wal = self.shared_wal.lock();
8009        wal.group_sync()?;
8010        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
8011        let existing: HashSet<(u64, u64)> =
8012            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
8013                .into_iter()
8014                .filter_map(|record| match record.op {
8015                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
8016                    _ => None,
8017                })
8018                .collect();
8019        selected.retain(|txn_id| {
8020            commits
8021                .get(txn_id)
8022                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
8023        });
8024        for record in records {
8025            if !selected.contains(&record.txn_id) {
8026                continue;
8027            }
8028            match &record.op {
8029                Op::TxnCommit { epoch, added_runs } => {
8030                    let timestamp = commit_timestamps
8031                        .get(&record.txn_id)
8032                        .copied()
8033                        .unwrap_or_else(current_unix_nanos);
8034                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
8035                }
8036                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
8037                op => {
8038                    wal.append(record.txn_id, 0, op.clone())?;
8039                }
8040            }
8041        }
8042        if !selected.is_empty() {
8043            wal.group_sync()?;
8044        }
8045        drop(wal);
8046
8047        // Auth mode is selected before `finish_open` replays the WAL. Make the
8048        // catalog transition durable and publish security state to this live
8049        // handle before reporting success.
8050        let mut recovered_catalog = self.catalog.read().clone();
8051        if let Err(error) = recover_ddl_from_wal(
8052            &self.root,
8053            Some(&self.durable_root),
8054            &mut recovered_catalog,
8055            self.meta_dek.as_ref(),
8056            wal_dek.as_ref(),
8057            true,
8058            None,
8059        ) {
8060            return Err(MongrelError::DurableCommit {
8061                epoch: target_epoch,
8062                message: format!(
8063                    "replication WAL is durable but catalog checkpoint failed: {error}"
8064                ),
8065            });
8066        }
8067        let _security = self.security_coordinator.gate.write();
8068        let old_security_version = self.catalog.read().security_version;
8069        let security_changed = old_security_version != recovered_catalog.security_version
8070            || self.catalog.read().require_auth != recovered_catalog.require_auth;
8071        let require_auth = recovered_catalog.require_auth;
8072        let principal = if security_changed {
8073            None
8074        } else {
8075            self.principal.read().as_ref().and_then(|principal| {
8076                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
8077            })
8078        };
8079        if require_auth {
8080            self.auth_state.set_require_auth(true);
8081        }
8082        self.auth_state.set_principal(principal.clone());
8083        *self.principal.write() = principal;
8084        let security_version = recovered_catalog.security_version;
8085        *self.catalog.write() = recovered_catalog;
8086        self.security_coordinator
8087            .version
8088            .store(security_version, Ordering::Release);
8089        if !require_auth {
8090            self.auth_state.set_require_auth(false);
8091        }
8092        if let Err(error) =
8093            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
8094        {
8095            return Err(MongrelError::DurableCommit {
8096                epoch: target_epoch,
8097                message: format!(
8098                    "replication WAL and catalog are durable but follower watermark failed: {error}"
8099                ),
8100            });
8101        }
8102        Ok(target_epoch)
8103    }
8104
8105    /// Resolve a table name → id (live tables only). pub(crate) so the
8106    /// transaction layer can stage by name.
8107    pub fn table_id(&self, name: &str) -> Result<u64> {
8108        let cat = self.catalog.read();
8109        cat.live(name)
8110            .map(|e| e.table_id)
8111            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
8112    }
8113
8114    /// Return the stable table id and current schema generation from one
8115    /// catalog snapshot. Callers can bind retries to this identity so a table
8116    /// dropped and recreated under the same name is never mistaken for the
8117    /// original resource.
8118    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
8119        let catalog = self.catalog.read();
8120        catalog
8121            .live(name)
8122            .map(|entry| (entry.table_id, entry.schema.schema_id))
8123            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
8124    }
8125
8126    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
8127        self.catalog
8128            .read()
8129            .building(name)
8130            .map(|entry| entry.table_id)
8131            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
8132    }
8133
8134    pub fn procedures(&self) -> Vec<StoredProcedure> {
8135        self.catalog
8136            .read()
8137            .procedures
8138            .iter()
8139            .map(|p| p.procedure.clone())
8140            .collect()
8141    }
8142
8143    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
8144        self.catalog
8145            .read()
8146            .procedures
8147            .iter()
8148            .find(|p| p.procedure.name == name)
8149            .map(|p| p.procedure.clone())
8150    }
8151
8152    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
8153        self.create_procedure_inner(procedure, None)
8154    }
8155
8156    pub fn create_procedure_controlled<F>(
8157        &self,
8158        procedure: StoredProcedure,
8159        mut before_publish: F,
8160    ) -> Result<StoredProcedure>
8161    where
8162        F: FnMut() -> Result<()>,
8163    {
8164        self.create_procedure_inner(procedure, Some(&mut before_publish))
8165    }
8166
8167    fn create_procedure_inner(
8168        &self,
8169        mut procedure: StoredProcedure,
8170        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8171    ) -> Result<StoredProcedure> {
8172        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8173            procedure: procedure.clone(),
8174        };
8175        self.require(&crate::catalog_cmds::required_permission(&command))?;
8176        let _g = self.ddl_lock.lock();
8177        let _security_write = self.security_write()?;
8178        self.require(&crate::catalog_cmds::required_permission(&command))?;
8179        procedure.validate()?;
8180        self.validate_procedure_references(&procedure)?;
8181        // S1F-001: validation runs pure against the current catalog first so
8182        // failures (duplicate name, unknown table) surface before an epoch is
8183        // consumed, exactly like the legacy pre-bump checks.
8184        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8185        let commit_lock = Arc::clone(&self.commit_lock);
8186        let _c = commit_lock.lock();
8187        let epoch = self.epoch.bump_assigned();
8188        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8189        procedure.created_epoch = epoch.0;
8190        procedure.updated_epoch = epoch.0;
8191        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8192            procedure: procedure.clone(),
8193        };
8194        let mut next_catalog = self.catalog.read().clone();
8195        self.apply_catalog_command_to(&mut next_catalog, command)?;
8196        next_catalog.db_epoch = epoch.0;
8197        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8198        Ok(procedure)
8199    }
8200
8201    pub fn create_or_replace_procedure(
8202        &self,
8203        procedure: StoredProcedure,
8204    ) -> Result<StoredProcedure> {
8205        self.create_or_replace_procedure_inner(procedure, None)
8206    }
8207
8208    pub fn create_or_replace_procedure_controlled<F>(
8209        &self,
8210        procedure: StoredProcedure,
8211        mut before_publish: F,
8212    ) -> Result<StoredProcedure>
8213    where
8214        F: FnMut() -> Result<()>,
8215    {
8216        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
8217    }
8218
8219    fn create_or_replace_procedure_inner(
8220        &self,
8221        procedure: StoredProcedure,
8222        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8223    ) -> Result<StoredProcedure> {
8224        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8225            procedure: procedure.clone(),
8226        };
8227        self.require(&crate::catalog_cmds::required_permission(&command))?;
8228        let _g = self.ddl_lock.lock();
8229        let _security_write = self.security_write()?;
8230        self.require(&crate::catalog_cmds::required_permission(&command))?;
8231        procedure.validate()?;
8232        self.validate_procedure_references(&procedure)?;
8233        // S1F-001: validation runs pure against the current catalog first so
8234        // structural failures surface before an epoch is consumed, exactly
8235        // like the legacy pre-bump checks.
8236        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8237        let commit_lock = Arc::clone(&self.commit_lock);
8238        let _c = commit_lock.lock();
8239        let epoch = self.epoch.bump_assigned();
8240        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8241        let mut next_catalog = self.catalog.read().clone();
8242        // Resolve the replacement image against the candidate catalog: the
8243        // engine stamps version/epochs from the commit epoch (preserving the
8244        // original created_epoch on replacement), then the command record
8245        // carries that resolved image.
8246        let replaced = match next_catalog
8247            .procedures
8248            .iter()
8249            .position(|p| p.procedure.name == procedure.name)
8250        {
8251            Some(idx) => next_catalog.procedures[idx]
8252                .procedure
8253                .replaced(procedure.clone(), epoch.0)?,
8254            None => {
8255                let mut next = procedure;
8256                next.created_epoch = epoch.0;
8257                next.updated_epoch = epoch.0;
8258                next
8259            }
8260        };
8261        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8262            procedure: replaced.clone(),
8263        };
8264        self.apply_catalog_command_to(&mut next_catalog, command)?;
8265        next_catalog.db_epoch = epoch.0;
8266        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8267        Ok(replaced)
8268    }
8269
8270    pub fn drop_procedure(&self, name: &str) -> Result<()> {
8271        self.drop_procedure_with_epoch(name).map(|_| ())
8272    }
8273
8274    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
8275        self.drop_procedure_with_epoch_inner(name, None)
8276    }
8277
8278    pub fn drop_procedure_with_epoch_controlled<F>(
8279        &self,
8280        name: &str,
8281        mut before_publish: F,
8282    ) -> Result<Epoch>
8283    where
8284        F: FnMut() -> Result<()>,
8285    {
8286        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
8287    }
8288
8289    fn drop_procedure_with_epoch_inner(
8290        &self,
8291        name: &str,
8292        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8293    ) -> Result<Epoch> {
8294        let command = crate::catalog_cmds::CatalogCommand::DropProcedure {
8295            name: name.to_string(),
8296        };
8297        self.require(&crate::catalog_cmds::required_permission(&command))?;
8298        let _g = self.ddl_lock.lock();
8299        let _security_write = self.security_write()?;
8300        self.require(&crate::catalog_cmds::required_permission(&command))?;
8301        let commit_lock = Arc::clone(&self.commit_lock);
8302        let _c = commit_lock.lock();
8303        let epoch = self.epoch.bump_assigned();
8304        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8305        let mut next_catalog = self.catalog.read().clone();
8306        self.apply_catalog_command_to(&mut next_catalog, command)?;
8307        next_catalog.db_epoch = epoch.0;
8308        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8309        Ok(epoch)
8310    }
8311
8312    // ── User / role / credentials management ─────────────────────────────
8313
8314    /// List all catalog users (password hashes included — callers should not
8315    /// serialize them externally).
8316    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
8317        self.catalog.read().users.clone()
8318    }
8319
8320    /// Resolve only the stable, non-secret identity fields needed to scope
8321    /// request receipts. Password hashes never leave the catalog lock.
8322    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
8323        self.catalog
8324            .read()
8325            .users
8326            .iter()
8327            .find(|user| user.username == username)
8328            .map(|user| (user.id, user.created_epoch))
8329    }
8330
8331    /// SCRAM verifier material for the current catalog user generation.
8332    pub fn user_scram_verifier(
8333        &self,
8334        username: &str,
8335    ) -> Option<crate::security_hardening::ScramVerifier> {
8336        self.catalog
8337            .read()
8338            .users
8339            .iter()
8340            .find(|user| user.username == username)
8341            .and_then(|user| user.scram_sha_256.clone())
8342    }
8343
8344    /// Current catalog authorization generation. Retry bindings can include
8345    /// this value to fail closed after roles, grants, or row policies change.
8346    pub fn security_version(&self) -> u64 {
8347        self.catalog.read().security_version
8348    }
8349
8350    /// List all catalog roles.
8351    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
8352        self.catalog.read().roles.clone()
8353    }
8354
8355    /// Create a new user with an Argon2id-hashed password.
8356    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
8357        self.require(&crate::auth::Permission::Admin)?;
8358        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
8359        let scram = crate::auth::scram_verifier(password).map_err(MongrelError::Other)?;
8360        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(password);
8361        self.create_user_with_password_hash_inner(username, hash, Some((scram, mysql)), None)
8362    }
8363
8364    /// Create a user from a password hash prepared before a commit fence.
8365    pub fn create_user_with_password_hash(
8366        &self,
8367        username: &str,
8368        hash: String,
8369    ) -> Result<crate::auth::UserEntry> {
8370        self.create_user_with_password_hash_inner(username, hash, None, None)
8371    }
8372
8373    pub fn create_user_with_password_hash_controlled<F>(
8374        &self,
8375        username: &str,
8376        hash: String,
8377        mut before_publish: F,
8378    ) -> Result<crate::auth::UserEntry>
8379    where
8380        F: FnMut() -> Result<()>,
8381    {
8382        self.create_user_with_password_hash_inner(username, hash, None, Some(&mut before_publish))
8383    }
8384
8385    fn create_user_with_password_hash_inner(
8386        &self,
8387        username: &str,
8388        hash: String,
8389        verifiers: Option<(
8390            crate::security_hardening::ScramVerifier,
8391            crate::auth::MysqlCachingSha2Verifier,
8392        )>,
8393        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8394    ) -> Result<crate::auth::UserEntry> {
8395        // S1F-001: the mutation is a versioned catalog command; its required
8396        // permission is checked against the caller principal first (identical
8397        // to the legacy hardcoded Admin gate).
8398        let command = match &verifiers {
8399            Some((scram_sha_256, mysql_caching_sha2)) => {
8400                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8401                    username: username.to_string(),
8402                    password_hash: hash.clone(),
8403                    scram_sha_256: scram_sha_256.clone(),
8404                    mysql_caching_sha2: mysql_caching_sha2.clone(),
8405                    is_admin: false,
8406                    created_epoch: 0,
8407                }
8408            }
8409            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8410                username: username.to_string(),
8411                password_hash: hash.clone(),
8412                is_admin: false,
8413                created_epoch: 0,
8414            },
8415        };
8416        self.require(&crate::catalog_cmds::required_permission(&command))?;
8417        let _ddl = self.ddl_lock.lock();
8418        let _security_write = self.security_write()?;
8419        self.require(&crate::catalog_cmds::required_permission(&command))?;
8420        let _commit = self.commit_lock.lock();
8421        let epoch = self.epoch.bump_assigned();
8422        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8423        let command = match verifiers {
8424            Some((scram_sha_256, mysql_caching_sha2)) => {
8425                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8426                    username: username.to_string(),
8427                    password_hash: hash,
8428                    scram_sha_256,
8429                    mysql_caching_sha2,
8430                    is_admin: false,
8431                    created_epoch: epoch.0,
8432                }
8433            }
8434            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8435                username: username.to_string(),
8436                password_hash: hash,
8437                is_admin: false,
8438                created_epoch: epoch.0,
8439            },
8440        };
8441        let mut next_catalog = self.catalog.read().clone();
8442        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8443        let crate::catalog_cmds::CatalogDelta::UserUpserted(entry) = delta else {
8444            return Err(MongrelError::Other(
8445                "CreateUser resolved to an unexpected catalog delta".into(),
8446            ));
8447        };
8448        next_catalog.db_epoch = epoch.0;
8449        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8450        Ok(entry)
8451    }
8452
8453    /// Drop a user by username.
8454    pub fn drop_user(&self, username: &str) -> Result<()> {
8455        self.drop_user_with_epoch(username).map(|_| ())
8456    }
8457
8458    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
8459        self.drop_user_with_epoch_inner(username, None)
8460    }
8461
8462    pub fn drop_user_with_epoch_controlled<F>(
8463        &self,
8464        username: &str,
8465        mut before_publish: F,
8466    ) -> Result<Epoch>
8467    where
8468        F: FnMut() -> Result<()>,
8469    {
8470        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
8471    }
8472
8473    fn drop_user_with_epoch_inner(
8474        &self,
8475        username: &str,
8476        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8477    ) -> Result<Epoch> {
8478        let command = crate::catalog_cmds::CatalogCommand::DropUser {
8479            username: username.to_string(),
8480        };
8481        self.require(&crate::catalog_cmds::required_permission(&command))?;
8482        let _ddl = self.ddl_lock.lock();
8483        let _security_write = self.security_write()?;
8484        self.require(&crate::catalog_cmds::required_permission(&command))?;
8485        let _commit = self.commit_lock.lock();
8486        let epoch = self.epoch.bump_assigned();
8487        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8488        let mut next_catalog = self.catalog.read().clone();
8489        self.apply_catalog_command_to(&mut next_catalog, command)?;
8490        next_catalog.db_epoch = epoch.0;
8491        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8492        Ok(epoch)
8493    }
8494
8495    /// Change a user's password.
8496    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
8497        self.alter_user_password_with_epoch(username, new_password)
8498            .map(|_| ())
8499    }
8500
8501    pub fn alter_user_password_with_epoch(
8502        &self,
8503        username: &str,
8504        new_password: &str,
8505    ) -> Result<Epoch> {
8506        self.require(&crate::auth::Permission::Admin)?;
8507        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
8508        let scram = crate::auth::scram_verifier(new_password).map_err(MongrelError::Other)?;
8509        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(new_password);
8510        self.alter_user_password_hash_with_epoch_inner(username, hash, Some((scram, mysql)), None)
8511    }
8512
8513    pub fn alter_user_password_hash_with_epoch(
8514        &self,
8515        username: &str,
8516        hash: String,
8517    ) -> Result<Epoch> {
8518        self.alter_user_password_hash_with_epoch_inner(username, hash, None, None)
8519    }
8520
8521    pub fn alter_user_password_hash_with_epoch_controlled<F>(
8522        &self,
8523        username: &str,
8524        hash: String,
8525        mut before_publish: F,
8526    ) -> Result<Epoch>
8527    where
8528        F: FnMut() -> Result<()>,
8529    {
8530        self.alter_user_password_hash_with_epoch_inner(
8531            username,
8532            hash,
8533            None,
8534            Some(&mut before_publish),
8535        )
8536    }
8537
8538    fn alter_user_password_hash_with_epoch_inner(
8539        &self,
8540        username: &str,
8541        hash: String,
8542        verifiers: Option<(
8543            crate::security_hardening::ScramVerifier,
8544            crate::auth::MysqlCachingSha2Verifier,
8545        )>,
8546        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8547    ) -> Result<Epoch> {
8548        let command = match verifiers {
8549            Some((scram_sha_256, mysql_caching_sha2)) => {
8550                crate::catalog_cmds::CatalogCommand::AlterUserPasswordWithAuthVerifiers {
8551                    username: username.to_string(),
8552                    password_hash: hash,
8553                    scram_sha_256,
8554                    mysql_caching_sha2,
8555                }
8556            }
8557            None => crate::catalog_cmds::CatalogCommand::AlterUserPassword {
8558                username: username.to_string(),
8559                password_hash: hash,
8560            },
8561        };
8562        self.require(&crate::catalog_cmds::required_permission(&command))?;
8563        let _ddl = self.ddl_lock.lock();
8564        let _security_write = self.security_write()?;
8565        self.require(&crate::catalog_cmds::required_permission(&command))?;
8566        let _commit = self.commit_lock.lock();
8567        let epoch = self.epoch.bump_assigned();
8568        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8569        let mut next_catalog = self.catalog.read().clone();
8570        self.apply_catalog_command_to(&mut next_catalog, command)?;
8571        next_catalog.db_epoch = epoch.0;
8572        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8573        Ok(epoch)
8574    }
8575
8576    /// Verify credentials. Returns `Some(entry)` on success, `None` on
8577    /// mismatch, `Err` on engine error.
8578    pub fn verify_user(
8579        &self,
8580        username: &str,
8581        password: &str,
8582    ) -> Result<Option<crate::auth::UserEntry>> {
8583        let cat = self.catalog.read();
8584        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
8585            return Ok(None);
8586        };
8587        if user.password_hash.is_empty() {
8588            return Ok(None);
8589        }
8590        let ok = crate::auth::verify_password(password, &user.password_hash)
8591            .map_err(MongrelError::Other)?;
8592        if ok {
8593            Ok(Some(user.clone()))
8594        } else {
8595            Ok(None)
8596        }
8597    }
8598
8599    /// Authenticate and resolve one immutable principal from the same catalog
8600    /// snapshot. Username reuse cannot bridge the password check and principal
8601    /// resolution.
8602    pub fn authenticate_principal(
8603        &self,
8604        username: &str,
8605        password: &str,
8606    ) -> Result<Option<crate::auth::Principal>> {
8607        self.authenticate_principal_inner(username, password, || {})
8608    }
8609
8610    fn authenticate_principal_inner<F>(
8611        &self,
8612        username: &str,
8613        password: &str,
8614        after_verify: F,
8615    ) -> Result<Option<crate::auth::Principal>>
8616    where
8617        F: FnOnce(),
8618    {
8619        let catalog = self.catalog.read();
8620        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
8621            return Ok(None);
8622        };
8623        if user.password_hash.is_empty()
8624            || !crate::auth::verify_password(password, &user.password_hash)
8625                .map_err(MongrelError::Other)?
8626        {
8627            return Ok(None);
8628        }
8629        after_verify();
8630        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
8631    }
8632
8633    /// Verify a MySQL 8 `caching_sha2_password` proof and resolve the same
8634    /// live catalog principal used by native SCRAM authentication.
8635    pub fn authenticate_mysql_caching_sha2_principal(
8636        &self,
8637        username: &str,
8638        nonce: &[u8],
8639        proof: &[u8],
8640    ) -> Option<crate::auth::Principal> {
8641        let catalog = self.catalog.read();
8642        let user = catalog
8643            .users
8644            .iter()
8645            .find(|user| user.username == username)?;
8646        if !user.mysql_caching_sha2.as_ref()?.verify(nonce, proof) {
8647            return None;
8648        }
8649        Self::resolve_user_principal_from_catalog(&catalog, user)
8650    }
8651
8652    /// Grant admin privileges to a user (bypasses all permission checks).
8653    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
8654        self.set_user_admin_with_epoch(username, is_admin)
8655            .map(|_| ())
8656    }
8657
8658    pub fn set_user_admin_with_epoch(
8659        &self,
8660        username: &str,
8661        is_admin: bool,
8662    ) -> Result<Option<Epoch>> {
8663        self.set_user_admin_with_epoch_inner(username, is_admin, None)
8664    }
8665
8666    pub fn set_user_admin_with_epoch_controlled<F>(
8667        &self,
8668        username: &str,
8669        is_admin: bool,
8670        mut before_publish: F,
8671    ) -> Result<Option<Epoch>>
8672    where
8673        F: FnMut() -> Result<()>,
8674    {
8675        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
8676    }
8677
8678    fn set_user_admin_with_epoch_inner(
8679        &self,
8680        username: &str,
8681        is_admin: bool,
8682        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8683    ) -> Result<Option<Epoch>> {
8684        let command = crate::catalog_cmds::CatalogCommand::SetUserAdmin {
8685            username: username.to_string(),
8686            is_admin,
8687        };
8688        self.require(&crate::catalog_cmds::required_permission(&command))?;
8689        let _ddl = self.ddl_lock.lock();
8690        let _security_write = self.security_write()?;
8691        self.require(&crate::catalog_cmds::required_permission(&command))?;
8692        let _commit = self.commit_lock.lock();
8693        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8694        // short-circuit), so validation runs pure first and only real changes
8695        // are applied and recorded.
8696        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8697        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8698            return Ok(None);
8699        }
8700        let epoch = self.epoch.bump_assigned();
8701        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8702        let mut next_catalog = self.catalog.read().clone();
8703        self.apply_catalog_command_to(&mut next_catalog, command)?;
8704        next_catalog.db_epoch = epoch.0;
8705        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8706        Ok(Some(epoch))
8707    }
8708
8709    /// Create a new role.
8710    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
8711        self.create_role_inner(name, None)
8712    }
8713
8714    pub fn create_role_controlled<F>(
8715        &self,
8716        name: &str,
8717        mut before_publish: F,
8718    ) -> Result<crate::auth::RoleEntry>
8719    where
8720        F: FnMut() -> Result<()>,
8721    {
8722        self.create_role_inner(name, Some(&mut before_publish))
8723    }
8724
8725    fn create_role_inner(
8726        &self,
8727        name: &str,
8728        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8729    ) -> Result<crate::auth::RoleEntry> {
8730        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8731            name: name.to_string(),
8732            created_epoch: 0, // stamped after the commit epoch is assigned
8733        };
8734        self.require(&crate::catalog_cmds::required_permission(&command))?;
8735        let _ddl = self.ddl_lock.lock();
8736        let _security_write = self.security_write()?;
8737        self.require(&crate::catalog_cmds::required_permission(&command))?;
8738        let _commit = self.commit_lock.lock();
8739        let epoch = self.epoch.bump_assigned();
8740        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8741        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8742            name: name.to_string(),
8743            created_epoch: epoch.0,
8744        };
8745        let mut next_catalog = self.catalog.read().clone();
8746        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8747        let crate::catalog_cmds::CatalogDelta::RoleUpserted(entry) = delta else {
8748            return Err(MongrelError::Other(
8749                "CreateRole resolved to an unexpected catalog delta".into(),
8750            ));
8751        };
8752        next_catalog.db_epoch = epoch.0;
8753        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8754        Ok(entry)
8755    }
8756
8757    /// Drop a role by name.
8758    pub fn drop_role(&self, name: &str) -> Result<()> {
8759        self.drop_role_with_epoch(name).map(|_| ())
8760    }
8761
8762    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
8763        self.drop_role_with_epoch_inner(name, None)
8764    }
8765
8766    pub fn drop_role_with_epoch_controlled<F>(
8767        &self,
8768        name: &str,
8769        mut before_publish: F,
8770    ) -> Result<Epoch>
8771    where
8772        F: FnMut() -> Result<()>,
8773    {
8774        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
8775    }
8776
8777    fn drop_role_with_epoch_inner(
8778        &self,
8779        name: &str,
8780        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8781    ) -> Result<Epoch> {
8782        let command = crate::catalog_cmds::CatalogCommand::DropRole {
8783            name: name.to_string(),
8784        };
8785        self.require(&crate::catalog_cmds::required_permission(&command))?;
8786        let _ddl = self.ddl_lock.lock();
8787        let _security_write = self.security_write()?;
8788        self.require(&crate::catalog_cmds::required_permission(&command))?;
8789        let _commit = self.commit_lock.lock();
8790        let epoch = self.epoch.bump_assigned();
8791        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8792        let mut next_catalog = self.catalog.read().clone();
8793        self.apply_catalog_command_to(&mut next_catalog, command)?;
8794        next_catalog.db_epoch = epoch.0;
8795        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8796        Ok(epoch)
8797    }
8798
8799    /// Grant a role to a user.
8800    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
8801        self.grant_role_with_epoch(username, role_name).map(|_| ())
8802    }
8803
8804    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8805        self.grant_role_with_epoch_inner(username, role_name, None)
8806    }
8807
8808    pub fn grant_role_with_epoch_controlled<F>(
8809        &self,
8810        username: &str,
8811        role_name: &str,
8812        mut before_publish: F,
8813    ) -> Result<Option<Epoch>>
8814    where
8815        F: FnMut() -> Result<()>,
8816    {
8817        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8818    }
8819
8820    fn grant_role_with_epoch_inner(
8821        &self,
8822        username: &str,
8823        role_name: &str,
8824        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8825    ) -> Result<Option<Epoch>> {
8826        let command = crate::catalog_cmds::CatalogCommand::GrantRole {
8827            username: username.to_string(),
8828            role: role_name.to_string(),
8829        };
8830        self.require(&crate::catalog_cmds::required_permission(&command))?;
8831        let _ddl = self.ddl_lock.lock();
8832        let _security_write = self.security_write()?;
8833        self.require(&crate::catalog_cmds::required_permission(&command))?;
8834        let _commit = self.commit_lock.lock();
8835        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8836        // short-circuit), so validation runs pure first and only real changes
8837        // are applied and recorded.
8838        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8839        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8840            return Ok(None);
8841        }
8842        let epoch = self.epoch.bump_assigned();
8843        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8844        let mut next_catalog = self.catalog.read().clone();
8845        self.apply_catalog_command_to(&mut next_catalog, command)?;
8846        next_catalog.db_epoch = epoch.0;
8847        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8848        Ok(Some(epoch))
8849    }
8850
8851    /// Revoke a role from a user.
8852    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
8853        self.revoke_role_with_epoch(username, role_name).map(|_| ())
8854    }
8855
8856    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8857        self.revoke_role_with_epoch_inner(username, role_name, None)
8858    }
8859
8860    pub fn revoke_role_with_epoch_controlled<F>(
8861        &self,
8862        username: &str,
8863        role_name: &str,
8864        mut before_publish: F,
8865    ) -> Result<Option<Epoch>>
8866    where
8867        F: FnMut() -> Result<()>,
8868    {
8869        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8870    }
8871
8872    fn revoke_role_with_epoch_inner(
8873        &self,
8874        username: &str,
8875        role_name: &str,
8876        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8877    ) -> Result<Option<Epoch>> {
8878        let command = crate::catalog_cmds::CatalogCommand::RevokeRole {
8879            username: username.to_string(),
8880            role: role_name.to_string(),
8881        };
8882        self.require(&crate::catalog_cmds::required_permission(&command))?;
8883        let _ddl = self.ddl_lock.lock();
8884        let _security_write = self.security_write()?;
8885        self.require(&crate::catalog_cmds::required_permission(&command))?;
8886        let _commit = self.commit_lock.lock();
8887        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8888        // short-circuit), so validation runs pure first and only real changes
8889        // are applied and recorded.
8890        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8891        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8892            return Ok(None);
8893        }
8894        let epoch = self.epoch.bump_assigned();
8895        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8896        let mut next_catalog = self.catalog.read().clone();
8897        self.apply_catalog_command_to(&mut next_catalog, command)?;
8898        next_catalog.db_epoch = epoch.0;
8899        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8900        Ok(Some(epoch))
8901    }
8902
8903    /// Grant a permission to a role.
8904    pub fn grant_permission(
8905        &self,
8906        role_name: &str,
8907        permission: crate::auth::Permission,
8908    ) -> Result<()> {
8909        self.grant_permission_with_epoch(role_name, permission)
8910            .map(|_| ())
8911    }
8912
8913    pub fn grant_permission_with_epoch(
8914        &self,
8915        role_name: &str,
8916        permission: crate::auth::Permission,
8917    ) -> Result<Option<Epoch>> {
8918        self.grant_permission_with_epoch_inner(role_name, permission, None)
8919    }
8920
8921    pub fn grant_permission_with_epoch_controlled<F>(
8922        &self,
8923        role_name: &str,
8924        permission: crate::auth::Permission,
8925        mut before_publish: F,
8926    ) -> Result<Option<Epoch>>
8927    where
8928        F: FnMut() -> Result<()>,
8929    {
8930        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8931    }
8932
8933    fn grant_permission_with_epoch_inner(
8934        &self,
8935        role_name: &str,
8936        permission: crate::auth::Permission,
8937        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8938    ) -> Result<Option<Epoch>> {
8939        let command = crate::catalog_cmds::CatalogCommand::GrantPermission {
8940            role: role_name.to_string(),
8941            permission,
8942        };
8943        self.require(&crate::catalog_cmds::required_permission(&command))?;
8944        let _ddl = self.ddl_lock.lock();
8945        let _security_write = self.security_write()?;
8946        self.require(&crate::catalog_cmds::required_permission(&command))?;
8947        let _commit = self.commit_lock.lock();
8948        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8949        // short-circuit), so validation runs pure first and only real changes
8950        // are applied and recorded.
8951        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8952        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8953            return Ok(None);
8954        }
8955        let epoch = self.epoch.bump_assigned();
8956        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8957        let mut next_catalog = self.catalog.read().clone();
8958        self.apply_catalog_command_to(&mut next_catalog, command)?;
8959        next_catalog.db_epoch = epoch.0;
8960        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8961        Ok(Some(epoch))
8962    }
8963
8964    /// Revoke a permission from a role.
8965    pub fn revoke_permission(
8966        &self,
8967        role_name: &str,
8968        permission: crate::auth::Permission,
8969    ) -> Result<()> {
8970        self.revoke_permission_with_epoch(role_name, permission)
8971            .map(|_| ())
8972    }
8973
8974    pub fn revoke_permission_with_epoch(
8975        &self,
8976        role_name: &str,
8977        permission: crate::auth::Permission,
8978    ) -> Result<Option<Epoch>> {
8979        self.revoke_permission_with_epoch_inner(role_name, permission, None)
8980    }
8981
8982    pub fn revoke_permission_with_epoch_controlled<F>(
8983        &self,
8984        role_name: &str,
8985        permission: crate::auth::Permission,
8986        mut before_publish: F,
8987    ) -> Result<Option<Epoch>>
8988    where
8989        F: FnMut() -> Result<()>,
8990    {
8991        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8992    }
8993
8994    fn revoke_permission_with_epoch_inner(
8995        &self,
8996        role_name: &str,
8997        permission: crate::auth::Permission,
8998        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8999    ) -> Result<Option<Epoch>> {
9000        let command = crate::catalog_cmds::CatalogCommand::RevokePermission {
9001            role: role_name.to_string(),
9002            permission,
9003        };
9004        self.require(&crate::catalog_cmds::required_permission(&command))?;
9005        let _ddl = self.ddl_lock.lock();
9006        let _security_write = self.security_write()?;
9007        self.require(&crate::catalog_cmds::required_permission(&command))?;
9008        let _commit = self.commit_lock.lock();
9009        // Idempotent no-ops publish nothing and consume no epoch (the legacy
9010        // short-circuit), so validation runs pure first and only real changes
9011        // are applied and recorded.
9012        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9013        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
9014            return Ok(None);
9015        }
9016        let epoch = self.epoch.bump_assigned();
9017        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9018        let mut next_catalog = self.catalog.read().clone();
9019        self.apply_catalog_command_to(&mut next_catalog, command)?;
9020        next_catalog.db_epoch = epoch.0;
9021        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9022        Ok(Some(epoch))
9023    }
9024
9025    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
9026    /// permissions from their roles. Returns `None` if the user doesn't exist.
9027    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
9028        let cat = self.catalog.read();
9029        Self::resolve_principal_from_catalog(&cat, username)
9030    }
9031
9032    /// Re-resolve only when the immutable user identity still exists. This is
9033    /// the server/session validation path; username reuse never matches.
9034    pub fn resolve_current_principal(
9035        &self,
9036        principal: &crate::auth::Principal,
9037    ) -> Option<crate::auth::Principal> {
9038        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
9039    }
9040
9041    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
9042    /// without needing a constructed `Database`. Used by the credentialed open
9043    /// path (which must verify credentials before the `Database` exists) and
9044    /// by [`resolve_principal`](Self::resolve_principal).
9045    fn resolve_principal_from_catalog(
9046        cat: &Catalog,
9047        username: &str,
9048    ) -> Option<crate::auth::Principal> {
9049        let user = cat.users.iter().find(|u| u.username == username)?;
9050        Self::resolve_user_principal_from_catalog(cat, user)
9051    }
9052
9053    fn resolve_bound_principal_from_catalog(
9054        cat: &Catalog,
9055        principal: &crate::auth::Principal,
9056    ) -> Option<crate::auth::Principal> {
9057        let user = cat.users.iter().find(|user| {
9058            user.id == principal.user_id
9059                && user.created_epoch == principal.created_epoch
9060                && user.username == principal.username
9061        })?;
9062        Self::resolve_user_principal_from_catalog(cat, user)
9063    }
9064
9065    fn resolve_user_principal_from_catalog(
9066        cat: &Catalog,
9067        user: &crate::auth::UserEntry,
9068    ) -> Option<crate::auth::Principal> {
9069        let mut permissions = Vec::new();
9070        for role_name in &user.roles {
9071            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
9072                permissions.extend(role.permissions.iter().cloned());
9073            }
9074        }
9075        Some(crate::auth::Principal {
9076            user_id: user.id,
9077            created_epoch: user.created_epoch,
9078            username: user.username.clone(),
9079            is_admin: user.is_admin,
9080            roles: user.roles.clone(),
9081            permissions,
9082        })
9083    }
9084
9085    /// Check whether a user has a specific permission (via their roles).
9086    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
9087        match self.resolve_principal(username) {
9088            Some(p) => p.has_permission(permission),
9089            None => false,
9090        }
9091    }
9092
9093    /// Returns `true` if this database's catalog has `require_auth = true`.
9094    /// When true, every operation consults the cached [`Principal`] via
9095    /// [`require`](Self::require).
9096    pub fn require_auth_enabled(&self) -> bool {
9097        self.catalog.read().require_auth
9098    }
9099
9100    /// A snapshot of the cached principal for this handle, if any. `None` for
9101    /// databases opened without credentials (the default). Returns a clone
9102    /// because the principal lives behind an `RwLock`.
9103    pub fn principal(&self) -> Option<crate::auth::Principal> {
9104        self.principal.read().clone()
9105    }
9106
9107    /// Build a `TableAuthChecker` from the current auth state. Used when
9108    /// mounting a new table (`create_table`) so the table inherits the
9109    /// database's enforcement configuration. The checker reads the live
9110    /// `require_auth` flag and cached principal, so changes via `enable_auth`
9111    /// / `refresh_principal` propagate to already-mounted tables.
9112    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
9113        if self.shared {
9114            return None;
9115        }
9116        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
9117            self.auth_state.clone(),
9118        )))
9119    }
9120
9121    /// Re-resolve the cached principal from the shared current catalog.
9122    /// Long-lived
9123    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
9124    /// possibly made by a different handle to the same database — to pick up
9125    /// the new effective permissions without re-verifying the password.
9126    ///
9127    /// The process-wide security version reloads from disk only when another
9128    /// handle published a newer catalog. The username is taken from
9129    /// the existing cached principal; if the user has since been dropped,
9130    /// returns [`MongrelError::InvalidCredentials`].
9131    ///
9132    /// No-op (returns `Ok(())`) on a credentialless database, or on a
9133    /// credentialed database whose cached principal is `None`.
9134    pub fn refresh_principal(&self) -> Result<()> {
9135        let previous = match self.principal.read().clone() {
9136            Some(principal) => principal,
9137            None => return Ok(()),
9138        };
9139        // Service principals are runtime identities, not catalog users. Their
9140        // immutable scopes are stored on the handle and never re-resolved by
9141        // username.
9142        if previous.user_id == 0 {
9143            return Ok(());
9144        }
9145        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
9146        self.refresh_security_catalog_if_stale(observed_version)?;
9147        let cat = self.catalog.read();
9148        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
9149            Some(p) => {
9150                *self.principal.write() = Some(p.clone());
9151                // Update the shared auth state so mounted Tables see the new
9152                // permissions immediately (Tables read from AuthState, not from
9153                // self.principal).
9154                self.auth_state.set_principal(Some(p));
9155                Ok(())
9156            }
9157            None => Err(MongrelError::InvalidCredentials {
9158                username: previous.username,
9159            }),
9160        }
9161    }
9162
9163    /// Number of security-catalog disk reloads performed by this open handle.
9164    /// Initial open reads are excluded.
9165    pub fn security_catalog_disk_read_count(&self) -> u64 {
9166        self.security_catalog_disk_reads.load(Ordering::Relaxed)
9167    }
9168
9169    /// Convert a credentialless database to a credentialed one: create the
9170    /// first admin user, set `require_auth = true`, and cache the admin
9171    /// principal on this handle so subsequent operations on the same handle
9172    /// continue to work. After this call, the database can only be reopened
9173    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
9174    ///
9175    /// Refuses if the database already has `require_auth = true`. This is
9176    /// the conversion path for existing databases; for fresh databases,
9177    /// `create_with_credentials` sets everything up atomically.
9178    ///
9179    /// See `docs/15-credential-enforcement.md`.
9180    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
9181        if self.shared {
9182            // Fail closed (spec §4.6): one shared handle must not flip the
9183            // core into an enforcement mode the other handles cannot observe.
9184            // Stage 1A shared cores stay credentialless; auth-mode transitions
9185            // require an exclusive `Database` owner. Per-handle principals
9186            // land with Stage 1D sessions.
9187            return Err(MongrelError::Conflict(
9188                "enable_auth requires an exclusive Database owner; shared cores reject \
9189                 auth-mode transitions"
9190                    .into(),
9191            ));
9192        }
9193        let password_hash =
9194            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
9195        let scram_sha_256 =
9196            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
9197        let mysql_caching_sha2 =
9198            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
9199        let _ddl = self.ddl_lock.lock();
9200        let _security_write = self.security_write()?;
9201        let _commit = self.commit_lock.lock();
9202        let epoch = self.epoch.bump_assigned();
9203        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9204        let mut next_catalog = self.catalog.read().clone();
9205        if next_catalog.require_auth {
9206            return Err(MongrelError::InvalidArgument(
9207                "database already has require_auth enabled".into(),
9208            ));
9209        }
9210        if next_catalog
9211            .users
9212            .iter()
9213            .any(|u| u.username == admin_username)
9214        {
9215            return Err(MongrelError::InvalidArgument(format!(
9216                "user {admin_username:?} already exists"
9217            )));
9218        }
9219        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
9220        let id = next_catalog.next_user_id;
9221        next_catalog.next_user_id = id
9222            .checked_add(1)
9223            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
9224        next_catalog.users.push(crate::auth::UserEntry {
9225            id,
9226            username: admin_username.to_string(),
9227            password_hash,
9228            scram_sha_256: Some(scram_sha_256),
9229            mysql_caching_sha2: Some(mysql_caching_sha2),
9230            roles: Vec::new(),
9231            is_admin: true,
9232            created_epoch: epoch.0,
9233        });
9234        next_catalog.require_auth = true;
9235        advance_security_version(&mut next_catalog)?;
9236        next_catalog.db_epoch = epoch.0;
9237        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9238        // Cache the admin principal on this handle + update the shared auth
9239        // state whenever rename published, even if directory fsync was
9240        // inconclusive.
9241        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9242            let principal = crate::auth::Principal {
9243                user_id: id,
9244                created_epoch: epoch.0,
9245                username: admin_username.to_string(),
9246                is_admin: true,
9247                roles: Vec::new(),
9248                permissions: Vec::new(),
9249            };
9250            *self.principal.write() = Some(principal.clone());
9251            self.auth_state.set_principal(Some(principal));
9252        }
9253        publish
9254    }
9255
9256    /// Disable `require_auth` on this database, reverting it to credentialless
9257    /// mode. This is the **recovery** path — it requires the handle to already
9258    /// be open (and therefore already authenticated if `require_auth` was on).
9259    ///
9260    /// After this call, the database can be reopened with plain
9261    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
9262    /// credentials. All existing users and roles are preserved in the catalog
9263    /// (so `require_auth` can be re-enabled without recreating them), but they
9264    /// are no longer consulted for enforcement.
9265    ///
9266    /// For true **offline** recovery (when credentials are lost and no
9267    /// authenticated handle is available), the caller opens the database
9268    /// directly via the catalog file (filesystem access required) and calls
9269    /// this method — see the CLI's `auth disable-offline` command.
9270    ///
9271    /// See `docs/15-credential-enforcement.md` §4.7.
9272    pub fn disable_auth(&self) -> Result<()> {
9273        let _ddl = self.ddl_lock.lock();
9274        let _security_write = self.security_write()?;
9275        let _commit = self.commit_lock.lock();
9276        let epoch = self.epoch.bump_assigned();
9277        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9278        let mut next_catalog = self.catalog.read().clone();
9279        if !next_catalog.require_auth {
9280            return Err(MongrelError::InvalidArgument(
9281                "database does not have require_auth enabled".into(),
9282            ));
9283        }
9284        next_catalog.require_auth = false;
9285        advance_security_version(&mut next_catalog)?;
9286        next_catalog.db_epoch = epoch.0;
9287        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9288        // Clear the cached principal — enforcement is now off.
9289        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9290            *self.principal.write() = None;
9291        }
9292        publish
9293    }
9294
9295    /// Enforcement check: if the catalog has `require_auth = true`, verify
9296    /// that the cached principal satisfies `perm`. Called by every
9297    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
9298    /// Table/Transaction/MongrelSession operations).
9299    ///
9300    /// On a credentialless database this is a no-op (`Ok(())`).
9301    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
9302        self.ensure_owner_process()?;
9303        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
9304            return Err(MongrelError::ReadOnlyReplica);
9305        }
9306        if self.principal.read().is_some() {
9307            self.refresh_principal().map_err(|error| match error {
9308                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
9309                error => error,
9310            })?;
9311        }
9312        if !self.catalog.read().require_auth {
9313            return Ok(());
9314        }
9315        let guard = self.principal.read();
9316        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
9317        if p.has_permission(perm) {
9318            Ok(())
9319        } else {
9320            Err(MongrelError::PermissionDenied {
9321                required: perm.clone(),
9322                principal: p.username.clone(),
9323            })
9324        }
9325    }
9326
9327    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
9328    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
9329    /// other callers that know the operation kind + table name but don't want
9330    /// to construct the full `Permission` enum value themselves.
9331    pub fn require_table(
9332        &self,
9333        table: &str,
9334        perm: crate::auth_state::RequiredPermission,
9335    ) -> Result<()> {
9336        self.require(&perm.into_permission(table))
9337    }
9338
9339    pub fn triggers(&self) -> Vec<StoredTrigger> {
9340        self.catalog
9341            .read()
9342            .triggers
9343            .iter()
9344            .map(|t| t.trigger.clone())
9345            .collect()
9346    }
9347
9348    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
9349        self.catalog
9350            .read()
9351            .triggers
9352            .iter()
9353            .find(|t| t.trigger.name == name)
9354            .map(|t| t.trigger.clone())
9355    }
9356
9357    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9358        self.create_trigger_inner(trigger, None, None)
9359    }
9360
9361    pub fn create_trigger_controlled<F>(
9362        &self,
9363        trigger: StoredTrigger,
9364        mut before_publish: F,
9365    ) -> Result<StoredTrigger>
9366    where
9367        F: FnMut() -> Result<()>,
9368    {
9369        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
9370    }
9371
9372    pub fn create_trigger_as_controlled<F>(
9373        &self,
9374        trigger: StoredTrigger,
9375        principal: Option<&crate::auth::Principal>,
9376        mut before_publish: F,
9377    ) -> Result<StoredTrigger>
9378    where
9379        F: FnMut() -> Result<()>,
9380    {
9381        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
9382    }
9383
9384    fn create_trigger_inner(
9385        &self,
9386        mut trigger: StoredTrigger,
9387        principal: Option<&crate::auth::Principal>,
9388        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9389    ) -> Result<StoredTrigger> {
9390        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9391            trigger: trigger.clone(),
9392        };
9393        self.require_for(
9394            principal,
9395            &crate::catalog_cmds::required_permission(&command),
9396        )?;
9397        let _g = self.ddl_lock.lock();
9398        let _security_write = self.security_write()?;
9399        self.require_for(
9400            principal,
9401            &crate::catalog_cmds::required_permission(&command),
9402        )?;
9403        trigger.validate()?;
9404        self.validate_trigger_references(&trigger)
9405            .map_err(trigger_validation_error)?;
9406        // S1F-001: validation runs pure against the current catalog first so
9407        // failures (duplicate name, unknown target) surface before an epoch is
9408        // consumed, exactly like the legacy pre-bump checks.
9409        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9410        let commit_lock = Arc::clone(&self.commit_lock);
9411        let _c = commit_lock.lock();
9412        let epoch = self.epoch.bump_assigned();
9413        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9414        trigger.created_epoch = epoch.0;
9415        trigger.updated_epoch = epoch.0;
9416        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9417            trigger: trigger.clone(),
9418        };
9419        let mut next_catalog = self.catalog.read().clone();
9420        self.apply_catalog_command_to(&mut next_catalog, command)?;
9421        next_catalog.db_epoch = epoch.0;
9422        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9423        Ok(trigger)
9424    }
9425
9426    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9427        self.create_or_replace_trigger_inner(trigger, None, None)
9428    }
9429
9430    pub fn create_or_replace_trigger_controlled<F>(
9431        &self,
9432        trigger: StoredTrigger,
9433        mut before_publish: F,
9434    ) -> Result<StoredTrigger>
9435    where
9436        F: FnMut() -> Result<()>,
9437    {
9438        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
9439    }
9440
9441    pub fn create_or_replace_trigger_as_controlled<F>(
9442        &self,
9443        trigger: StoredTrigger,
9444        principal: Option<&crate::auth::Principal>,
9445        mut before_publish: F,
9446    ) -> Result<StoredTrigger>
9447    where
9448        F: FnMut() -> Result<()>,
9449    {
9450        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
9451    }
9452
9453    fn create_or_replace_trigger_inner(
9454        &self,
9455        trigger: StoredTrigger,
9456        principal: Option<&crate::auth::Principal>,
9457        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9458    ) -> Result<StoredTrigger> {
9459        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9460            trigger: trigger.clone(),
9461        };
9462        self.require_for(
9463            principal,
9464            &crate::catalog_cmds::required_permission(&command),
9465        )?;
9466        let _g = self.ddl_lock.lock();
9467        let _security_write = self.security_write()?;
9468        self.require_for(
9469            principal,
9470            &crate::catalog_cmds::required_permission(&command),
9471        )?;
9472        trigger.validate()?;
9473        self.validate_trigger_references(&trigger)
9474            .map_err(trigger_validation_error)?;
9475        // S1F-001: validation runs pure against the current catalog first so
9476        // structural failures surface before an epoch is consumed, exactly
9477        // like the legacy pre-bump checks.
9478        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9479        let commit_lock = Arc::clone(&self.commit_lock);
9480        let _c = commit_lock.lock();
9481        let epoch = self.epoch.bump_assigned();
9482        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9483        let mut next_catalog = self.catalog.read().clone();
9484        // Resolve the replacement image against the candidate catalog: the
9485        // engine stamps version/epochs from the commit epoch (preserving the
9486        // original created_epoch on replacement), then the command record
9487        // carries that resolved image.
9488        let replaced = match next_catalog
9489            .triggers
9490            .iter()
9491            .position(|t| t.trigger.name == trigger.name)
9492        {
9493            Some(idx) => next_catalog.triggers[idx]
9494                .trigger
9495                .replaced(trigger.clone(), epoch.0)?,
9496            None => {
9497                let mut next = trigger;
9498                next.created_epoch = epoch.0;
9499                next.updated_epoch = epoch.0;
9500                next
9501            }
9502        };
9503        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9504            trigger: replaced.clone(),
9505        };
9506        self.apply_catalog_command_to(&mut next_catalog, command)?;
9507        next_catalog.db_epoch = epoch.0;
9508        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9509        Ok(replaced)
9510    }
9511
9512    pub fn drop_trigger(&self, name: &str) -> Result<()> {
9513        self.drop_trigger_with_epoch(name).map(|_| ())
9514    }
9515
9516    /// Drop one trigger and return the exact catalog publication epoch.
9517    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
9518        self.drop_triggers_with_epoch(&[name.to_string()])
9519    }
9520
9521    pub fn drop_trigger_with_epoch_controlled<F>(
9522        &self,
9523        name: &str,
9524        before_publish: F,
9525    ) -> Result<Epoch>
9526    where
9527        F: FnMut() -> Result<()>,
9528    {
9529        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
9530    }
9531
9532    /// Atomically drop several triggers in one catalog publication.
9533    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
9534        self.drop_triggers_with_epoch_inner(names, None, None)
9535    }
9536
9537    pub fn drop_triggers_with_epoch_controlled<F>(
9538        &self,
9539        names: &[String],
9540        mut before_publish: F,
9541    ) -> Result<Epoch>
9542    where
9543        F: FnMut() -> Result<()>,
9544    {
9545        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
9546    }
9547
9548    pub fn drop_triggers_with_epoch_as_controlled<F>(
9549        &self,
9550        names: &[String],
9551        principal: Option<&crate::auth::Principal>,
9552        mut before_publish: F,
9553    ) -> Result<Epoch>
9554    where
9555        F: FnMut() -> Result<()>,
9556    {
9557        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
9558    }
9559
9560    fn drop_triggers_with_epoch_inner(
9561        &self,
9562        names: &[String],
9563        principal: Option<&crate::auth::Principal>,
9564        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9565    ) -> Result<Epoch> {
9566        if names.is_empty() {
9567            return Err(MongrelError::InvalidArgument(
9568                "at least one trigger name is required".into(),
9569            ));
9570        }
9571        let commands: Vec<crate::catalog_cmds::CatalogCommand> = names
9572            .iter()
9573            .map(|name| crate::catalog_cmds::CatalogCommand::DropTrigger { name: name.clone() })
9574            .collect();
9575        for command in &commands {
9576            self.require_for(
9577                principal,
9578                &crate::catalog_cmds::required_permission(command),
9579            )?;
9580        }
9581        let _g = self.ddl_lock.lock();
9582        let _security_write = self.security_write()?;
9583        for command in &commands {
9584            self.require_for(
9585                principal,
9586                &crate::catalog_cmds::required_permission(command),
9587            )?;
9588        }
9589        // S1F-001: every name's command validates pure against the current
9590        // catalog first so a missing trigger surfaces before an epoch is
9591        // consumed, exactly like the legacy pre-bump check. A batch drop then
9592        // records one command per name (the emitting-layer expansion rule).
9593        {
9594            let cat = self.catalog.read();
9595            for command in &commands {
9596                crate::catalog_cmds::apply(&cat, command)?;
9597            }
9598        }
9599        let commit_lock = Arc::clone(&self.commit_lock);
9600        let _c = commit_lock.lock();
9601        let epoch = self.epoch.bump_assigned();
9602        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9603        let mut next_catalog = self.catalog.read().clone();
9604        for command in commands {
9605            self.apply_catalog_command_to(&mut next_catalog, command)?;
9606        }
9607        next_catalog.db_epoch = epoch.0;
9608        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9609        Ok(epoch)
9610    }
9611
9612    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
9613        self.catalog.read().external_tables.clone()
9614    }
9615
9616    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
9617        self.catalog
9618            .read()
9619            .external_tables
9620            .iter()
9621            .find(|t| t.name == name)
9622            .cloned()
9623    }
9624
9625    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
9626        self.create_external_table_inner(entry, None)
9627    }
9628
9629    pub fn create_external_table_controlled<F>(
9630        &self,
9631        entry: ExternalTableEntry,
9632        mut before_publish: F,
9633    ) -> Result<ExternalTableEntry>
9634    where
9635        F: FnMut() -> Result<()>,
9636    {
9637        self.create_external_table_inner(entry, Some(&mut before_publish))
9638    }
9639
9640    fn create_external_table_inner(
9641        &self,
9642        mut entry: ExternalTableEntry,
9643        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9644    ) -> Result<ExternalTableEntry> {
9645        self.require(&crate::auth::Permission::Ddl)?;
9646        let _g = self.ddl_lock.lock();
9647        let _security_write = self.security_write()?;
9648        self.require(&crate::auth::Permission::Ddl)?;
9649        entry.validate()?;
9650        {
9651            let cat = self.catalog.read();
9652            if cat.live(&entry.name).is_some()
9653                || cat.external_tables.iter().any(|t| t.name == entry.name)
9654            {
9655                return Err(MongrelError::InvalidArgument(format!(
9656                    "table {:?} already exists",
9657                    entry.name
9658                )));
9659            }
9660        }
9661        let commit_lock = Arc::clone(&self.commit_lock);
9662        let _c = commit_lock.lock();
9663        // A prior durable drop may have left connector state behind if its
9664        // cleanup failed or the process crashed. Never let a new table with
9665        // the same name inherit that stale state.
9666        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
9667        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
9668        let epoch = self.epoch.bump_assigned();
9669        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9670        entry.created_epoch = epoch.0;
9671        let mut next_catalog = self.catalog.read().clone();
9672        next_catalog.external_tables.push(entry.clone());
9673        next_catalog.db_epoch = epoch.0;
9674        self.publish_catalog_candidate_with_prelude(
9675            next_catalog,
9676            epoch,
9677            &mut _epoch_guard,
9678            before_publish,
9679            vec![(
9680                EXTERNAL_TABLE_ID,
9681                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9682                    name: entry.name.clone(),
9683                    generation_epoch: epoch.0,
9684                }),
9685            )],
9686        )?;
9687        Ok(entry)
9688    }
9689
9690    pub fn drop_external_table(&self, name: &str) -> Result<()> {
9691        self.drop_external_table_with_epoch(name).map(|_| ())
9692    }
9693
9694    /// Drop an external table and return the exact catalog publication epoch.
9695    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
9696        self.drop_external_table_with_epoch_inner(name, None)
9697    }
9698
9699    pub fn drop_external_table_with_epoch_controlled<F>(
9700        &self,
9701        name: &str,
9702        mut before_publish: F,
9703    ) -> Result<Epoch>
9704    where
9705        F: FnMut() -> Result<()>,
9706    {
9707        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
9708    }
9709
9710    fn drop_external_table_with_epoch_inner(
9711        &self,
9712        name: &str,
9713        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9714    ) -> Result<Epoch> {
9715        self.require(&crate::auth::Permission::Ddl)?;
9716        let _g = self.ddl_lock.lock();
9717        let _security_write = self.security_write()?;
9718        self.require(&crate::auth::Permission::Ddl)?;
9719        let commit_lock = Arc::clone(&self.commit_lock);
9720        let _c = commit_lock.lock();
9721        let epoch = self.epoch.bump_assigned();
9722        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9723        let mut next_catalog = self.catalog.read().clone();
9724        let before = next_catalog.external_tables.len();
9725        next_catalog.external_tables.retain(|t| t.name != name);
9726        if next_catalog.external_tables.len() == before {
9727            return Err(MongrelError::NotFound(format!(
9728                "external table {name:?} not found"
9729            )));
9730        }
9731        next_catalog.db_epoch = epoch.0;
9732        self.publish_catalog_candidate_with_prelude(
9733            next_catalog,
9734            epoch,
9735            &mut _epoch_guard,
9736            before_publish,
9737            vec![(
9738                EXTERNAL_TABLE_ID,
9739                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9740                    name: name.to_string(),
9741                    generation_epoch: epoch.0,
9742                }),
9743            )],
9744        )?;
9745        let state_dir = self.root.join(VTAB_DIR).join(name);
9746        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
9747            return Err(MongrelError::DurableCommit {
9748                epoch: epoch.0,
9749                message: format!(
9750                    "external table was dropped but connector-state cleanup failed: {error}"
9751                ),
9752            });
9753        }
9754        Ok(epoch)
9755    }
9756
9757    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
9758        let txn_id = self.alloc_txn_id()?;
9759        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9760        self.commit_transaction_with_external_states(
9761            txn_id,
9762            self.epoch.visible(),
9763            Vec::new(),
9764            vec![(name.to_string(), state.to_vec())],
9765            Vec::new(),
9766            principal,
9767            catalog_bound,
9768            None,
9769            crate::txn::TxnCommitContext::internal(),
9770        )
9771        .map(|(epoch, _)| epoch)
9772    }
9773
9774    pub fn trigger_config(&self) -> TriggerConfig {
9775        use std::sync::atomic::Ordering;
9776        TriggerConfig {
9777            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
9778            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
9779            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
9780        }
9781    }
9782
9783    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
9784        use std::sync::atomic::Ordering;
9785        if config.max_depth == 0 {
9786            return Err(MongrelError::InvalidArgument(
9787                "trigger max_depth must be greater than 0".into(),
9788            ));
9789        }
9790        self.trigger_recursive
9791            .store(config.recursive_triggers, Ordering::Relaxed);
9792        self.trigger_max_depth
9793            .store(config.max_depth, Ordering::Relaxed);
9794        self.trigger_max_loop_iterations
9795            .store(config.max_loop_iterations, Ordering::Relaxed);
9796        Ok(())
9797    }
9798
9799    pub fn set_recursive_triggers(&self, recursive: bool) {
9800        use std::sync::atomic::Ordering;
9801        self.trigger_recursive.store(recursive, Ordering::Relaxed);
9802    }
9803
9804    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
9805    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
9806    /// as a low-latency wake-up.
9807    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
9808        self.notify.subscribe()
9809    }
9810
9811    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
9812        self.change_wake.subscribe()
9813    }
9814
9815    /// Reconstruct committed row changes from the retained shared WAL. Event
9816    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
9817    /// resumes before the oldest retained commit receives `gap = true` and
9818    /// must rebootstrap instead of silently skipping changes.
9819    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
9820        let control = crate::ExecutionControl::new(None);
9821        self.change_events_since_controlled(last_event_id, &control)
9822    }
9823
9824    /// Reconstruct committed changes with cooperative cancellation and bounds.
9825    pub fn change_events_since_controlled(
9826        &self,
9827        last_event_id: Option<&str>,
9828        control: &crate::ExecutionControl,
9829    ) -> Result<CdcBatch> {
9830        use crate::wal::Op;
9831
9832        control.checkpoint()?;
9833        let resume = match last_event_id {
9834            Some(id) => {
9835                let (epoch, index) = id.split_once(':').ok_or_else(|| {
9836                    MongrelError::InvalidArgument(format!(
9837                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
9838                    ))
9839                })?;
9840                Some((
9841                    epoch.parse::<u64>().map_err(|error| {
9842                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
9843                    })?,
9844                    index.parse::<u32>().map_err(|error| {
9845                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
9846                    })?,
9847                ))
9848            }
9849            None => None,
9850        };
9851
9852        let mut wal = self.shared_wal.lock();
9853        wal.group_sync()?;
9854        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
9855        let records = crate::wal::SharedWal::replay_with_dek_controlled(
9856            &self.root,
9857            wal_dek.as_ref(),
9858            control,
9859            CDC_MAX_WAL_RECORDS,
9860            CDC_MAX_WAL_REPLAY_BYTES,
9861        )?;
9862        drop(wal);
9863        control.checkpoint()?;
9864
9865        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
9866        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
9867        for (index, record) in records.iter().enumerate() {
9868            if index % 256 == 0 {
9869                control.checkpoint()?;
9870            }
9871            if let Op::TxnCommit { epoch, added_runs } = &record.op {
9872                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
9873            }
9874            if let Op::SpilledRows { table_id, rows } = &record.op {
9875                spilled_payloads
9876                    .entry((record.txn_id, *table_id))
9877                    .or_default()
9878                    .push(rows);
9879            }
9880        }
9881        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
9882        let current_epoch = self.epoch.committed().0;
9883        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
9884        let gap = resume.is_some_and(|(epoch, _)| {
9885            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
9886        });
9887        if gap {
9888            return Ok(CdcBatch {
9889                events: Vec::new(),
9890                current_epoch,
9891                earliest_epoch,
9892                gap: true,
9893            });
9894        }
9895
9896        let table_names: HashMap<u64, String> = self
9897            .catalog
9898            .read()
9899            .tables
9900            .iter()
9901            .map(|entry| (entry.table_id, entry.name.clone()))
9902            .collect();
9903        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
9904        let mut retained_bytes = 0_usize;
9905        for (index, record) in records.iter().enumerate() {
9906            if index % 256 == 0 {
9907                control.checkpoint()?;
9908            }
9909            if !commits.contains_key(&record.txn_id) {
9910                continue;
9911            }
9912            let Op::BeforeImage {
9913                table_id,
9914                row_id,
9915                row,
9916            } = &record.op
9917            else {
9918                continue;
9919            };
9920            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9921                return Err(MongrelError::ResourceLimitExceeded {
9922                    resource: "CDC before-image bytes",
9923                    requested: row.len(),
9924                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9925                });
9926            }
9927            let before: crate::memtable::Row = bincode::deserialize(row)?;
9928            if before_images.len() >= CDC_MAX_ROWS {
9929                return Err(MongrelError::ResourceLimitExceeded {
9930                    resource: "CDC before-image rows",
9931                    requested: before_images.len().saturating_add(1),
9932                    limit: CDC_MAX_ROWS,
9933                });
9934            }
9935            charge_cdc_bytes(
9936                &mut retained_bytes,
9937                cdc_row_storage_bytes(&before),
9938                "CDC retained bytes",
9939            )?;
9940            before_images.insert((record.txn_id, *table_id, row_id.0), before);
9941        }
9942        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
9943        let mut events = Vec::new();
9944        let mut decoded_rows = before_images.len();
9945        for (record_index, record) in records.iter().enumerate() {
9946            if record_index % 256 == 0 {
9947                control.checkpoint()?;
9948            }
9949            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
9950                continue;
9951            };
9952            let event = match &record.op {
9953                Op::Put { table_id, rows } => {
9954                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9955                        return Err(MongrelError::ResourceLimitExceeded {
9956                            resource: "CDC inline row bytes",
9957                            requested: rows.len(),
9958                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9959                        });
9960                    }
9961                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
9962                    decoded_rows = decoded_rows.saturating_add(rows.len());
9963                    if decoded_rows > CDC_MAX_ROWS {
9964                        return Err(MongrelError::ResourceLimitExceeded {
9965                            resource: "CDC decoded rows",
9966                            requested: decoded_rows,
9967                            limit: CDC_MAX_ROWS,
9968                        });
9969                    }
9970                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
9971                    let mut peak_bytes = retained_bytes;
9972                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9973                    let data = serde_json::to_value(rows)
9974                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
9975                    Some((*table_id, "put", data, event_bytes))
9976                }
9977                Op::Delete { table_id, row_ids } => {
9978                    let before = row_ids
9979                        .iter()
9980                        .filter_map(|row_id| {
9981                            before_images
9982                                .get(&(record.txn_id, *table_id, row_id.0))
9983                                .cloned()
9984                        })
9985                        .collect::<Vec<_>>();
9986                    let event_bytes = cdc_rows_json_bytes(&before)
9987                        .saturating_add(
9988                            row_ids
9989                                .len()
9990                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
9991                        )
9992                        .saturating_add(512);
9993                    let mut peak_bytes = retained_bytes;
9994                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9995                    Some((
9996                        *table_id,
9997                        "delete",
9998                        serde_json::json!({
9999                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
10000                            "before": before,
10001                        }),
10002                        event_bytes,
10003                    ))
10004                }
10005                Op::TruncateTable { table_id } => {
10006                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
10007                }
10008                _ => None,
10009            };
10010            if let Some((table_id, op, data, event_bytes)) = event {
10011                let index = operation_indices.entry(record.txn_id).or_insert(0);
10012                let event_position = (*commit_epoch, *index);
10013                *index = index.saturating_add(1);
10014                if resume.is_some_and(|position| event_position <= position) {
10015                    continue;
10016                }
10017                if events.len() >= CDC_MAX_EVENTS {
10018                    return Err(MongrelError::ResourceLimitExceeded {
10019                        resource: "CDC events",
10020                        requested: events.len().saturating_add(1),
10021                        limit: CDC_MAX_EVENTS,
10022                    });
10023                }
10024                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
10025                events.push(ChangeEvent {
10026                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
10027                    channel: "changes".into(),
10028                    table_id: Some(table_id),
10029                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
10030                    op: op.into(),
10031                    epoch: *commit_epoch,
10032                    txn_id: Some(record.txn_id),
10033                    message: None,
10034                    data: Some(data),
10035                });
10036            }
10037            if let Op::TxnCommit { added_runs, .. } = &record.op {
10038                for run in added_runs {
10039                    control.checkpoint()?;
10040                    let index = operation_indices.entry(record.txn_id).or_insert(0);
10041                    let event_position = (*commit_epoch, *index);
10042                    *index = index.saturating_add(1);
10043                    if resume.is_some_and(|position| event_position <= position) {
10044                        continue;
10045                    }
10046                    let mut rows = if let Some(payloads) =
10047                        spilled_payloads.get(&(record.txn_id, run.table_id))
10048                    {
10049                        let mut rows = Vec::new();
10050                        for payload in payloads {
10051                            control.checkpoint()?;
10052                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
10053                                return Err(MongrelError::ResourceLimitExceeded {
10054                                    resource: "CDC spilled row bytes",
10055                                    requested: payload.len(),
10056                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
10057                                });
10058                            }
10059                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
10060                            if decoded_rows
10061                                .saturating_add(rows.len())
10062                                .saturating_add(chunk.len())
10063                                > CDC_MAX_ROWS
10064                            {
10065                                return Err(MongrelError::ResourceLimitExceeded {
10066                                    resource: "CDC decoded rows",
10067                                    requested: decoded_rows
10068                                        .saturating_add(rows.len())
10069                                        .saturating_add(chunk.len()),
10070                                    limit: CDC_MAX_ROWS,
10071                                });
10072                            }
10073                            rows.extend(chunk);
10074                        }
10075                        rows
10076                    } else {
10077                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
10078                            return Ok(CdcBatch {
10079                                events: Vec::new(),
10080                                current_epoch,
10081                                earliest_epoch,
10082                                gap: true,
10083                            });
10084                        };
10085                        let table = handle.lock();
10086                        let mut reader = match table.open_reader(run.run_id) {
10087                            Ok(reader) => reader,
10088                            Err(_) => {
10089                                return Ok(CdcBatch {
10090                                    events: Vec::new(),
10091                                    current_epoch,
10092                                    earliest_epoch,
10093                                    gap: true,
10094                                })
10095                            }
10096                        };
10097                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
10098                        let rows = reader.all_rows_controlled(control, remaining)?;
10099                        drop(reader);
10100                        drop(table);
10101                        rows
10102                    };
10103                    for row in &mut rows {
10104                        row.committed_epoch = Epoch(*commit_epoch);
10105                    }
10106                    decoded_rows = decoded_rows.saturating_add(rows.len());
10107                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
10108                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
10109                    if events.len() >= CDC_MAX_EVENTS {
10110                        return Err(MongrelError::ResourceLimitExceeded {
10111                            resource: "CDC events",
10112                            requested: events.len().saturating_add(1),
10113                            limit: CDC_MAX_EVENTS,
10114                        });
10115                    }
10116                    events.push(ChangeEvent {
10117                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
10118                        channel: "changes".into(),
10119                        table_id: Some(run.table_id),
10120                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
10121                        op: "put_run".into(),
10122                        epoch: *commit_epoch,
10123                        txn_id: Some(record.txn_id),
10124                        message: None,
10125                        data: Some(serde_json::json!({
10126                            "run_id": run.run_id.to_string(),
10127                            "row_count": run.row_count,
10128                            "min_row_id": run.min_row_id,
10129                            "max_row_id": run.max_row_id,
10130                            "rows": rows,
10131                        })),
10132                    });
10133                }
10134            }
10135        }
10136        control.checkpoint()?;
10137        Ok(CdcBatch {
10138            events,
10139            current_epoch,
10140            earliest_epoch,
10141            gap: false,
10142        })
10143    }
10144
10145    /// Publish a notification message on a named channel. Reaches all active
10146    /// subscribers (daemon `/events`, application listeners).
10147    pub fn notify(&self, channel: &str, message: Option<String>) {
10148        let _ = self.notify.send(ChangeEvent {
10149            id: None,
10150            channel: channel.to_string(),
10151            table_id: None,
10152            table: String::new(),
10153            op: "notify".into(),
10154            epoch: self.epoch.visible().0,
10155            txn_id: None,
10156            message,
10157            data: None,
10158        });
10159    }
10160
10161    pub fn call_procedure(
10162        &self,
10163        name: &str,
10164        args: HashMap<String, crate::Value>,
10165    ) -> Result<ProcedureCallResult> {
10166        self.call_procedure_as(name, args, None)
10167    }
10168
10169    pub fn call_procedure_as(
10170        &self,
10171        name: &str,
10172        args: HashMap<String, crate::Value>,
10173        principal: Option<&crate::auth::Principal>,
10174    ) -> Result<ProcedureCallResult> {
10175        let control = crate::ExecutionControl::new(None);
10176        self.call_procedure_as_controlled(name, args, principal, &control, || true)
10177    }
10178
10179    /// Execute only the exact procedure revision previously authorized by the
10180    /// caller. A dropped or replaced definition fails closed.
10181    #[doc(hidden)]
10182    pub fn call_procedure_as_bound(
10183        &self,
10184        expected: &StoredProcedure,
10185        args: HashMap<String, crate::Value>,
10186        principal: Option<&crate::auth::Principal>,
10187    ) -> Result<ProcedureCallResult> {
10188        self.require_for(principal, &crate::auth::Permission::All)?;
10189        let procedure = self.procedure(&expected.name).ok_or_else(|| {
10190            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
10191        })?;
10192        if &procedure != expected {
10193            return Err(MongrelError::Conflict(format!(
10194                "procedure {:?} changed after request authorization",
10195                expected.name
10196            )));
10197        }
10198        let control = crate::ExecutionControl::new(None);
10199        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
10200    }
10201
10202    /// Execute a procedure with cooperative cancellation during preparation.
10203    /// `before_commit` runs after every procedure step has succeeded and
10204    /// immediately before a write procedure commits. Returning `false` aborts
10205    /// the transaction without publishing it.
10206    #[doc(hidden)]
10207    pub fn call_procedure_as_controlled<F>(
10208        &self,
10209        name: &str,
10210        args: HashMap<String, crate::Value>,
10211        principal: Option<&crate::auth::Principal>,
10212        control: &crate::ExecutionControl,
10213        before_commit: F,
10214    ) -> Result<ProcedureCallResult>
10215    where
10216        F: FnOnce() -> bool,
10217    {
10218        // v1 requires ALL to call procedures on a require_auth database; a
10219        // finer SECURITY DEFINER-style marker is a future extension (spec §9
10220        // decision 1).
10221        self.require_for(principal, &crate::auth::Permission::All)?;
10222        let procedure = self
10223            .procedure(name)
10224            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
10225        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
10226    }
10227
10228    fn execute_procedure_as_controlled<F>(
10229        &self,
10230        procedure: StoredProcedure,
10231        args: HashMap<String, crate::Value>,
10232        principal: Option<&crate::auth::Principal>,
10233        control: &crate::ExecutionControl,
10234        before_commit: F,
10235    ) -> Result<ProcedureCallResult>
10236    where
10237        F: FnOnce() -> bool,
10238    {
10239        let args = bind_procedure_args(&procedure, args)?;
10240        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
10241        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
10242        if has_writes {
10243            let mut tx = self.begin_as(principal.cloned());
10244            let run = (|| {
10245                for (step_index, step) in procedure.body.steps.iter().enumerate() {
10246                    if step_index % 256 == 0 {
10247                        control.checkpoint()?;
10248                    }
10249                    let output = self.execute_procedure_step(
10250                        step,
10251                        &args,
10252                        &outputs,
10253                        Some(&mut tx),
10254                        principal,
10255                        Some(control),
10256                    )?;
10257                    outputs.insert(step.id().to_string(), output);
10258                }
10259                control.checkpoint()?;
10260                eval_return_output(&procedure.body.return_value, &args, &outputs)
10261            })();
10262            match run {
10263                Ok(output) => {
10264                    control.checkpoint()?;
10265                    if !before_commit() {
10266                        tx.rollback();
10267                        return Err(MongrelError::Cancelled);
10268                    }
10269                    let epoch = tx.commit()?.0;
10270                    Ok(ProcedureCallResult {
10271                        epoch: Some(epoch),
10272                        output,
10273                    })
10274                }
10275                Err(e) => {
10276                    tx.rollback();
10277                    Err(e)
10278                }
10279            }
10280        } else {
10281            for (step_index, step) in procedure.body.steps.iter().enumerate() {
10282                if step_index % 256 == 0 {
10283                    control.checkpoint()?;
10284                }
10285                let output = self.execute_procedure_step(
10286                    step,
10287                    &args,
10288                    &outputs,
10289                    None,
10290                    principal,
10291                    Some(control),
10292                )?;
10293                outputs.insert(step.id().to_string(), output);
10294            }
10295            control.checkpoint()?;
10296            Ok(ProcedureCallResult {
10297                epoch: None,
10298                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
10299            })
10300        }
10301    }
10302
10303    fn execute_procedure_step(
10304        &self,
10305        step: &ProcedureStep,
10306        args: &HashMap<String, crate::Value>,
10307        outputs: &HashMap<String, ProcedureCallOutput>,
10308        tx: Option<&mut crate::txn::Transaction<'_>>,
10309        principal: Option<&crate::auth::Principal>,
10310        control: Option<&crate::ExecutionControl>,
10311    ) -> Result<ProcedureCallOutput> {
10312        if let Some(control) = control {
10313            control.checkpoint()?;
10314        }
10315        match step {
10316            ProcedureStep::NativeQuery {
10317                table,
10318                conditions,
10319                projection,
10320                limit,
10321                ..
10322            } => {
10323                let mut q = crate::Query::new();
10324                for condition in conditions {
10325                    q = q.and(eval_condition(condition, args, outputs)?);
10326                }
10327                let fallback_control = crate::ExecutionControl::new(None);
10328                let query_control = control.unwrap_or(&fallback_control);
10329                let mut rows = self.query_for_principal_controlled(
10330                    table,
10331                    &q,
10332                    projection.as_deref(),
10333                    principal,
10334                    false,
10335                    query_control,
10336                )?;
10337                if let Some(limit) = limit {
10338                    rows.truncate(*limit);
10339                }
10340                let mut output = Vec::with_capacity(rows.len());
10341                for (row_index, row) in rows.into_iter().enumerate() {
10342                    if row_index % 256 == 0 {
10343                        if let Some(control) = control {
10344                            control.checkpoint()?;
10345                        }
10346                    }
10347                    output.push(ProcedureCallRow {
10348                        row_id: Some(row.row_id),
10349                        columns: row.columns,
10350                    });
10351                }
10352                Ok(ProcedureCallOutput::Rows(output))
10353            }
10354            ProcedureStep::Put {
10355                table,
10356                cells,
10357                returning,
10358                ..
10359            } => {
10360                let tx = tx.ok_or_else(|| {
10361                    MongrelError::InvalidArgument(
10362                        "write procedure step requires a transaction".into(),
10363                    )
10364                })?;
10365                let cells = eval_cells(cells, args, outputs)?;
10366                if *returning {
10367                    let out = tx.put_returning(table, cells)?;
10368                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10369                        row_id: None,
10370                        columns: out.row.columns.into_iter().collect(),
10371                    }))
10372                } else {
10373                    tx.put(table, cells)?;
10374                    Ok(ProcedureCallOutput::Null)
10375                }
10376            }
10377            ProcedureStep::Upsert {
10378                table,
10379                cells,
10380                update_cells,
10381                returning,
10382                ..
10383            } => {
10384                let tx = tx.ok_or_else(|| {
10385                    MongrelError::InvalidArgument(
10386                        "write procedure step requires a transaction".into(),
10387                    )
10388                })?;
10389                let cells = eval_cells(cells, args, outputs)?;
10390                let action = match update_cells {
10391                    Some(update_cells) => {
10392                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
10393                    }
10394                    None => crate::UpsertAction::DoNothing,
10395                };
10396                let out = tx.upsert(table, cells, action)?;
10397                if *returning {
10398                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10399                        row_id: None,
10400                        columns: out.row.columns.into_iter().collect(),
10401                    }))
10402                } else {
10403                    Ok(ProcedureCallOutput::Null)
10404                }
10405            }
10406            ProcedureStep::DeleteByPk { table, pk, .. } => {
10407                let tx = tx.ok_or_else(|| {
10408                    MongrelError::InvalidArgument(
10409                        "write procedure step requires a transaction".into(),
10410                    )
10411                })?;
10412                let pk = eval_value(pk, args, outputs)?;
10413                let handle = self.table(table)?;
10414                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
10415                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
10416                })?;
10417                tx.delete(table, row_id)?;
10418                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
10419            }
10420            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
10421                "DeleteRows procedure step is not supported by the core executor yet".into(),
10422            )),
10423            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
10424                "SqlQuery procedure step must be executed by mongreldb-query".into(),
10425            )),
10426        }
10427    }
10428
10429    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
10430        let cat = self.catalog.read();
10431        for step in &procedure.body.steps {
10432            let Some(table_name) = step.table() else {
10433                continue;
10434            };
10435            let schema = &cat
10436                .live(table_name)
10437                .ok_or_else(|| {
10438                    MongrelError::InvalidArgument(format!(
10439                        "procedure {:?} references unknown table {table_name:?}",
10440                        procedure.name
10441                    ))
10442                })?
10443                .schema;
10444            match step {
10445                ProcedureStep::NativeQuery {
10446                    conditions,
10447                    projection,
10448                    ..
10449                } => {
10450                    for condition in conditions {
10451                        validate_condition_columns(condition, schema)?;
10452                    }
10453                    if let Some(projection) = projection {
10454                        for id in projection {
10455                            validate_column_id(*id, schema)?;
10456                        }
10457                    }
10458                }
10459                ProcedureStep::Put { cells, .. } => {
10460                    for cell in cells {
10461                        validate_column_id(cell.column_id, schema)?;
10462                    }
10463                }
10464                ProcedureStep::Upsert {
10465                    cells,
10466                    update_cells,
10467                    ..
10468                } => {
10469                    for cell in cells {
10470                        validate_column_id(cell.column_id, schema)?;
10471                    }
10472                    if let Some(update_cells) = update_cells {
10473                        for cell in update_cells {
10474                            validate_column_id(cell.column_id, schema)?;
10475                        }
10476                    }
10477                }
10478                ProcedureStep::DeleteByPk { .. } => {
10479                    if schema.primary_key().is_none() {
10480                        return Err(MongrelError::InvalidArgument(format!(
10481                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
10482                            procedure.name
10483                        )));
10484                    }
10485                }
10486                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
10487            }
10488        }
10489        Ok(())
10490    }
10491
10492    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
10493        let cat = self.catalog.read();
10494        let target_schema = match &trigger.target {
10495            TriggerTarget::Table(target_name) => cat
10496                .live(target_name)
10497                .ok_or_else(|| {
10498                    MongrelError::InvalidArgument(format!(
10499                        "trigger {:?} references unknown target table {target_name:?}",
10500                        trigger.name
10501                    ))
10502                })?
10503                .schema
10504                .clone(),
10505            TriggerTarget::View(_) => Schema {
10506                columns: trigger.target_columns.clone(),
10507                ..Schema::default()
10508            },
10509        };
10510        for col in &trigger.update_of {
10511            if target_schema.column(col).is_none() {
10512                return Err(MongrelError::InvalidArgument(format!(
10513                    "trigger {:?} UPDATE OF references unknown column {col:?}",
10514                    trigger.name
10515                )));
10516            }
10517        }
10518        if let Some(expr) = &trigger.when {
10519            validate_trigger_expr(expr, &target_schema, trigger.event)?;
10520        }
10521        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
10522        for step in &trigger.program.steps {
10523            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
10524            {
10525                return Err(MongrelError::InvalidArgument(
10526                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
10527                ));
10528            }
10529            validate_trigger_step(
10530                step,
10531                &cat,
10532                &target_schema,
10533                trigger.event,
10534                &mut select_schemas,
10535            )?;
10536        }
10537        Ok(())
10538    }
10539
10540    /// Begin a new transaction reading at the current visible epoch.
10541    pub fn begin(&self) -> crate::txn::Transaction<'_> {
10542        self.begin_with_isolation(crate::txn::IsolationLevel::default())
10543    }
10544
10545    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
10546        let principal = self.principal.read().clone();
10547        let catalog_bound = principal
10548            .as_ref()
10549            .is_some_and(|principal| principal.user_id != 0);
10550        (principal, catalog_bound)
10551    }
10552
10553    pub fn begin_as(
10554        &self,
10555        principal: Option<crate::auth::Principal>,
10556    ) -> crate::txn::Transaction<'_> {
10557        let catalog_bound = principal
10558            .as_ref()
10559            .is_some_and(|principal| principal.user_id != 0);
10560        let txn_id = self.alloc_txn_id();
10561        let read = self.visible_snapshot();
10562        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10563            .with_principal(principal, catalog_bound)
10564    }
10565
10566    /// Begin a transaction with a specific isolation level.
10567    pub fn begin_with_isolation(
10568        &self,
10569        level: crate::txn::IsolationLevel,
10570    ) -> crate::txn::Transaction<'_> {
10571        let txn_id = self.alloc_txn_id();
10572        // Every level pins the current visible epoch + HLC at begin; ReadCommitted
10573        // re-pins per statement inside the transaction (S1B-002 / P0.5).
10574        let read = self.visible_snapshot();
10575        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10576        crate::txn::Transaction::new(self, txn_id, read, level)
10577            .with_principal(principal, catalog_bound)
10578    }
10579
10580    /// Begin a transaction whose trigger programs may route external-table DML
10581    /// through an application/query-layer module bridge.
10582    pub fn begin_with_external_trigger_bridge<'a>(
10583        &'a self,
10584        bridge: &'a dyn ExternalTriggerBridge,
10585    ) -> crate::txn::Transaction<'a> {
10586        let txn_id = self.alloc_txn_id();
10587        let read = self.visible_snapshot();
10588        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10589        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10590            .with_external_trigger_bridge(bridge)
10591            .with_principal(principal, catalog_bound)
10592    }
10593
10594    pub fn begin_with_external_trigger_bridge_as<'a>(
10595        &'a self,
10596        bridge: &'a dyn ExternalTriggerBridge,
10597        principal: Option<crate::auth::Principal>,
10598    ) -> crate::txn::Transaction<'a> {
10599        let catalog_bound = principal
10600            .as_ref()
10601            .is_some_and(|principal| principal.user_id != 0);
10602        let txn_id = self.alloc_txn_id();
10603        let read = self.visible_snapshot();
10604        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10605            .with_external_trigger_bridge(bridge)
10606            .with_principal(principal, catalog_bound)
10607    }
10608
10609    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
10610    pub fn transaction<T>(
10611        &self,
10612        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10613    ) -> Result<T> {
10614        let mut tx = self.begin();
10615        match f(&mut tx) {
10616            Ok(out) => {
10617                tx.commit()?;
10618                Ok(out)
10619            }
10620            Err(e) => {
10621                tx.rollback();
10622                Err(e)
10623            }
10624        }
10625    }
10626
10627    pub fn transaction_with_row_ids<T>(
10628        &self,
10629        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10630    ) -> Result<(T, Vec<RowId>)> {
10631        let mut tx = self.begin();
10632        match f(&mut tx) {
10633            Ok(output) => {
10634                let (_, row_ids) = tx.commit_with_row_ids()?;
10635                Ok((output, row_ids))
10636            }
10637            Err(error) => {
10638                tx.rollback();
10639                Err(error)
10640            }
10641        }
10642    }
10643
10644    pub fn transaction_for_current_principal<T>(
10645        &self,
10646        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10647    ) -> Result<T> {
10648        if self.principal.read().is_some() {
10649            self.refresh_principal()?;
10650        }
10651        let mut transaction = self.begin_as(self.principal.read().clone());
10652        match f(&mut transaction) {
10653            Ok(output) => {
10654                transaction.commit()?;
10655                Ok(output)
10656            }
10657            Err(error) => {
10658                transaction.rollback();
10659                Err(error)
10660            }
10661        }
10662    }
10663
10664    pub fn transaction_for_current_principal_with_epoch<T>(
10665        &self,
10666        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10667    ) -> Result<(Epoch, T)> {
10668        if self.principal.read().is_some() {
10669            self.refresh_principal()?;
10670        }
10671        let mut transaction = self.begin_as(self.principal.read().clone());
10672        match f(&mut transaction) {
10673            Ok(output) => {
10674                let epoch = transaction.commit()?;
10675                Ok((epoch, output))
10676            }
10677            Err(error) => {
10678                transaction.rollback();
10679                Err(error)
10680            }
10681        }
10682    }
10683
10684    pub fn transaction_with_row_ids_for_current_principal<T>(
10685        &self,
10686        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10687    ) -> Result<(T, Vec<RowId>)> {
10688        if self.principal.read().is_some() {
10689            self.refresh_principal()?;
10690        }
10691        let mut transaction = self.begin_as(self.principal.read().clone());
10692        match f(&mut transaction) {
10693            Ok(output) => {
10694                let (_, row_ids) = transaction.commit_with_row_ids()?;
10695                Ok((output, row_ids))
10696            }
10697            Err(error) => {
10698                transaction.rollback();
10699                Err(error)
10700            }
10701        }
10702    }
10703
10704    /// Run `f` in a transaction with an external-trigger bridge; commit on
10705    /// `Ok`, rollback on `Err`.
10706    pub fn transaction_with_external_trigger_bridge<'a, T>(
10707        &'a self,
10708        bridge: &'a dyn ExternalTriggerBridge,
10709        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10710    ) -> Result<T> {
10711        let mut tx = self.begin_with_external_trigger_bridge(bridge);
10712        match f(&mut tx) {
10713            Ok(out) => {
10714                tx.commit()?;
10715                Ok(out)
10716            }
10717            Err(e) => {
10718                tx.rollback();
10719                Err(e)
10720            }
10721        }
10722    }
10723
10724    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
10725        &'a self,
10726        bridge: &'a dyn ExternalTriggerBridge,
10727        principal: Option<crate::auth::Principal>,
10728        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10729    ) -> Result<T> {
10730        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
10731        match f(&mut tx) {
10732            Ok(output) => {
10733                tx.commit()?;
10734                Ok(output)
10735            }
10736            Err(error) => {
10737                tx.rollback();
10738                Err(error)
10739            }
10740        }
10741    }
10742
10743    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
10744    /// `Transaction::new` so registration happens **before** any read.
10745    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
10746        self.active_txns.register(epoch)
10747    }
10748
10749    fn fill_auto_increment_for_staging(
10750        &self,
10751        txn_id: u64,
10752        staging: &mut [(u64, crate::txn::Staged)],
10753        control: Option<&crate::ExecutionControl>,
10754    ) -> Result<()> {
10755        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
10756        for (index, (table_id, staged)) in staging.iter().enumerate() {
10757            commit_prepare_checkpoint(control, index)?;
10758            if matches!(staged, crate::txn::Staged::Put(_)) {
10759                puts_by_table.entry(*table_id).or_default().push(index);
10760            }
10761        }
10762
10763        // S1B-003: sequence allocation serializes on one Exclusive barrier per
10764        // staged table whose puts actually ALLOCATE an auto-increment value
10765        // (absent/Null column — explicit values only advance the counter and
10766        // must not serialize), held until the transaction ends so allocated
10767        // values map monotonically onto commit order. The barrier is acquired
10768        // before the table lock (never the reverse).
10769        {
10770            let mut barrier_tables: Vec<u64> = puts_by_table
10771                .iter()
10772                .filter(|(table_id, indexes)| {
10773                    indexes.iter().any(|index| {
10774                        matches!(
10775                            &staging[*index].1,
10776                            crate::txn::Staged::Put(cells)
10777                                if self.table_auto_inc_would_allocate(**table_id, cells)
10778                        )
10779                    })
10780                })
10781                .map(|(table_id, _)| *table_id)
10782                .collect();
10783            barrier_tables.sort_unstable();
10784            for table_id in barrier_tables {
10785                self.acquire_txn_lock(
10786                    txn_id,
10787                    crate::locks::LockKey::sequence_barrier(&format!("auto_inc:{table_id}")),
10788                    crate::locks::LockMode::Exclusive,
10789                    control,
10790                )?;
10791            }
10792        }
10793
10794        let tables = self.tables.read();
10795        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
10796            commit_prepare_checkpoint(control, table_index)?;
10797            if let Some(handle) = tables.get(&table_id) {
10798                #[cfg(test)]
10799                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10800                let mut t = handle.lock();
10801                for (fill_index, index) in indexes.into_iter().enumerate() {
10802                    commit_prepare_checkpoint(control, fill_index)?;
10803                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
10804                        t.fill_auto_inc(cells)?;
10805                    }
10806                }
10807            }
10808        }
10809        Ok(())
10810    }
10811
10812    fn expand_table_triggers(
10813        &self,
10814        txn_id: u64,
10815        staging: &mut Vec<(u64, crate::txn::Staged)>,
10816        read_epoch: Epoch,
10817        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
10818        external_states: &mut Vec<(String, Vec<u8>)>,
10819        control: Option<&crate::ExecutionControl>,
10820    ) -> Result<()> {
10821        commit_prepare_checkpoint(control, 0)?;
10822        let mut external_writes = Vec::new();
10823        let config = self.trigger_config();
10824        if config.recursive_triggers {
10825            let chunk = std::mem::take(staging);
10826            let stacks = vec![Vec::new(); chunk.len()];
10827            *staging = self.expand_trigger_chunk(
10828                txn_id,
10829                chunk,
10830                stacks,
10831                read_epoch,
10832                0,
10833                config.max_depth,
10834                &mut external_writes,
10835                &config,
10836                control,
10837            )?;
10838            self.apply_external_trigger_writes(
10839                external_writes,
10840                external_trigger_bridge,
10841                external_states,
10842                staging,
10843                control,
10844            )?;
10845            return Ok(());
10846        }
10847
10848        let mut expansion =
10849            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
10850        if !expansion.before.is_empty() {
10851            let mut final_staging = expansion.before;
10852            final_staging.extend(filter_ignored_staging(
10853                std::mem::take(staging),
10854                &expansion.ignored_indices,
10855            ));
10856            *staging = final_staging;
10857        } else if !expansion.ignored_indices.is_empty() {
10858            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
10859        }
10860        staging.append(&mut expansion.after);
10861        external_writes.append(&mut expansion.before_external);
10862        external_writes.append(&mut expansion.after_external);
10863        self.apply_external_trigger_writes(
10864            external_writes,
10865            external_trigger_bridge,
10866            external_states,
10867            staging,
10868            control,
10869        )?;
10870        Ok(())
10871    }
10872
10873    #[allow(clippy::too_many_arguments)]
10874    fn expand_trigger_chunk(
10875        &self,
10876        txn_id: u64,
10877        mut chunk: Vec<(u64, crate::txn::Staged)>,
10878        stacks: Vec<Vec<String>>,
10879        read_epoch: Epoch,
10880        depth: u32,
10881        max_depth: u32,
10882        external_writes: &mut Vec<ExternalTriggerWrite>,
10883        config: &TriggerConfig,
10884        control: Option<&crate::ExecutionControl>,
10885    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
10886        if chunk.is_empty() {
10887            return Ok(Vec::new());
10888        }
10889        commit_prepare_checkpoint(control, 0)?;
10890        self.fill_auto_increment_for_staging(txn_id, &mut chunk, control)?;
10891        let expansion = self.expand_table_triggers_once(
10892            &mut chunk,
10893            read_epoch,
10894            Some(&stacks),
10895            config,
10896            control,
10897        )?;
10898        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
10899            let stack = expansion
10900                .before_stacks
10901                .first()
10902                .or_else(|| expansion.after_stacks.first())
10903                .cloned()
10904                .unwrap_or_default();
10905            return Err(MongrelError::TriggerValidation(format!(
10906                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
10907                Self::format_trigger_stack(&stack)
10908            )));
10909        }
10910
10911        let mut out = Vec::new();
10912        external_writes.extend(expansion.before_external);
10913        out.extend(self.expand_trigger_chunk(
10914            txn_id,
10915            expansion.before,
10916            expansion.before_stacks,
10917            read_epoch,
10918            depth + 1,
10919            max_depth,
10920            external_writes,
10921            config,
10922            control,
10923        )?);
10924        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
10925        external_writes.extend(expansion.after_external);
10926        out.extend(self.expand_trigger_chunk(
10927            txn_id,
10928            expansion.after,
10929            expansion.after_stacks,
10930            read_epoch,
10931            depth + 1,
10932            max_depth,
10933            external_writes,
10934            config,
10935            control,
10936        )?);
10937        Ok(out)
10938    }
10939
10940    fn apply_external_trigger_writes(
10941        &self,
10942        writes: Vec<ExternalTriggerWrite>,
10943        bridge: Option<&dyn ExternalTriggerBridge>,
10944        external_states: &mut Vec<(String, Vec<u8>)>,
10945        staging: &mut Vec<(u64, crate::txn::Staged)>,
10946        control: Option<&crate::ExecutionControl>,
10947    ) -> Result<()> {
10948        if writes.is_empty() {
10949            return Ok(());
10950        }
10951        let bridge = bridge.ok_or_else(|| {
10952            MongrelError::TriggerValidation(
10953                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
10954            )
10955        })?;
10956        for (write_index, write) in writes.into_iter().enumerate() {
10957            commit_prepare_checkpoint(control, write_index)?;
10958            let table = write.table().to_string();
10959            let entry = self.external_table(&table).ok_or_else(|| {
10960                MongrelError::NotFound(format!("external table {table:?} not found"))
10961            })?;
10962            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
10963            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
10964            external_states.push((table, result.state));
10965            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
10966                commit_prepare_checkpoint(control, base_index)?;
10967                match base_write {
10968                    ExternalTriggerBaseWrite::Put { table, cells } => {
10969                        let table_id = self.table_id(&table)?;
10970                        staging.push((table_id, crate::txn::Staged::Put(cells)));
10971                    }
10972                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
10973                        let table_id = self.table_id(&table)?;
10974                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
10975                    }
10976                }
10977            }
10978        }
10979        dedup_external_states_in_place(external_states);
10980        Ok(())
10981    }
10982
10983    fn expand_table_triggers_once(
10984        &self,
10985        staging: &mut Vec<(u64, crate::txn::Staged)>,
10986        read_epoch: Epoch,
10987        trigger_stacks: Option<&[Vec<String>]>,
10988        config: &TriggerConfig,
10989        control: Option<&crate::ExecutionControl>,
10990    ) -> Result<TriggerExpansion> {
10991        commit_prepare_checkpoint(control, 0)?;
10992        let triggers: Vec<StoredTrigger> = self
10993            .catalog
10994            .read()
10995            .triggers
10996            .iter()
10997            .filter(|entry| {
10998                entry.trigger.enabled
10999                    && matches!(
11000                        entry.trigger.timing,
11001                        TriggerTiming::Before | TriggerTiming::After
11002                    )
11003                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
11004            })
11005            .map(|entry| entry.trigger.clone())
11006            .collect();
11007        if triggers.is_empty() || staging.is_empty() {
11008            return Ok(TriggerExpansion::default());
11009        }
11010
11011        let before_triggers = triggers
11012            .iter()
11013            .filter(|trigger| trigger.timing == TriggerTiming::Before)
11014            .cloned()
11015            .collect::<Vec<_>>();
11016        let after_triggers = triggers
11017            .iter()
11018            .filter(|trigger| trigger.timing == TriggerTiming::After)
11019            .cloned()
11020            .collect::<Vec<_>>();
11021
11022        let mut before_added = Vec::new();
11023        let mut before_stacks = Vec::new();
11024        let mut before_external = Vec::new();
11025        let mut ignored_indices = std::collections::BTreeSet::new();
11026        if !before_triggers.is_empty() {
11027            let before_events =
11028                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
11029            let mut out = TriggerProgramOutput {
11030                added: &mut before_added,
11031                added_stacks: &mut before_stacks,
11032                added_external: &mut before_external,
11033                ignored_indices: &mut ignored_indices,
11034            };
11035            self.execute_triggers_for_events(
11036                &before_triggers,
11037                &before_events,
11038                Some(staging),
11039                &mut out,
11040                config,
11041                read_epoch,
11042                control,
11043            )?;
11044        }
11045
11046        let after_events = if after_triggers.is_empty() {
11047            Vec::new()
11048        } else {
11049            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
11050                .into_iter()
11051                .filter(|event| {
11052                    !event
11053                        .op_indices
11054                        .iter()
11055                        .any(|idx| ignored_indices.contains(idx))
11056                })
11057                .collect()
11058        };
11059
11060        let mut after_added = Vec::new();
11061        let mut after_stacks = Vec::new();
11062        let mut after_external = Vec::new();
11063        let mut out = TriggerProgramOutput {
11064            added: &mut after_added,
11065            added_stacks: &mut after_stacks,
11066            added_external: &mut after_external,
11067            ignored_indices: &mut ignored_indices,
11068        };
11069        self.execute_triggers_for_events(
11070            &after_triggers,
11071            &after_events,
11072            None,
11073            &mut out,
11074            config,
11075            read_epoch,
11076            control,
11077        )?;
11078        Ok(TriggerExpansion {
11079            before: before_added,
11080            before_stacks,
11081            before_external,
11082            after: after_added,
11083            after_stacks,
11084            after_external,
11085            ignored_indices,
11086        })
11087    }
11088
11089    #[allow(clippy::too_many_arguments)]
11090    fn execute_triggers_for_events(
11091        &self,
11092        triggers: &[StoredTrigger],
11093        events: &[WriteEvent],
11094        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11095        out: &mut TriggerProgramOutput<'_>,
11096        config: &TriggerConfig,
11097        read_epoch: Epoch,
11098        control: Option<&crate::ExecutionControl>,
11099    ) -> Result<()> {
11100        let mut checkpoint_index = 0_usize;
11101        for event in events {
11102            for trigger in triggers {
11103                commit_prepare_checkpoint(control, checkpoint_index)?;
11104                checkpoint_index += 1;
11105                if event
11106                    .op_indices
11107                    .iter()
11108                    .any(|idx| out.ignored_indices.contains(idx))
11109                {
11110                    break;
11111                }
11112                let matches = {
11113                    let cat = self.catalog.read();
11114                    trigger_matches_event(trigger, event, &cat)?
11115                };
11116                if !matches {
11117                    continue;
11118                }
11119                if let Some(when) = &trigger.when {
11120                    if !eval_trigger_expr(when, event)? {
11121                        continue;
11122                    }
11123                }
11124                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
11125                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
11126                    return Err(MongrelError::TriggerValidation(format!(
11127                        "trigger recursion cycle detected; trigger stack: {}",
11128                        Self::format_trigger_stack(&trigger_stack)
11129                    )));
11130                }
11131                let outcome = match staging.as_mut() {
11132                    Some(staging) => self.execute_trigger_program(
11133                        trigger,
11134                        event,
11135                        Some(&mut **staging),
11136                        out,
11137                        &trigger_stack,
11138                        config,
11139                        read_epoch,
11140                        control,
11141                    )?,
11142                    None => self.execute_trigger_program(
11143                        trigger,
11144                        event,
11145                        None,
11146                        out,
11147                        &trigger_stack,
11148                        config,
11149                        read_epoch,
11150                        control,
11151                    )?,
11152                };
11153                if outcome == TriggerProgramOutcome::Ignore {
11154                    out.ignored_indices.extend(event.op_indices.iter().copied());
11155                    break;
11156                }
11157            }
11158        }
11159        Ok(())
11160    }
11161
11162    fn trigger_events_for_staging(
11163        &self,
11164        staging: &[(u64, crate::txn::Staged)],
11165        read_epoch: Epoch,
11166        trigger_stacks: Option<&[Vec<String>]>,
11167        control: Option<&crate::ExecutionControl>,
11168    ) -> Result<Vec<WriteEvent>> {
11169        use crate::txn::Staged;
11170        use std::collections::{HashMap, VecDeque};
11171
11172        let snapshot = self.snapshot_for_epoch(read_epoch);
11173        let cat = self.catalog.read();
11174        let mut table_names = HashMap::new();
11175        let mut table_schemas = HashMap::new();
11176        for entry in cat
11177            .tables
11178            .iter()
11179            .filter(|entry| matches!(entry.state, TableState::Live))
11180        {
11181            table_names.insert(entry.table_id, entry.name.clone());
11182            table_schemas.insert(entry.table_id, entry.schema.clone());
11183        }
11184        drop(cat);
11185
11186        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
11187        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11188        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11189
11190        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11191            commit_prepare_checkpoint(control, idx)?;
11192            let Some(schema) = table_schemas.get(table_id) else {
11193                continue;
11194            };
11195            let Some(pk) = schema.primary_key() else {
11196                continue;
11197            };
11198            match staged {
11199                Staged::Delete(row_id) => {
11200                    let handle = self.table_by_id(*table_id)?;
11201                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
11202                        continue;
11203                    };
11204                    let Some(pk_value) = row.columns.get(&pk.id) else {
11205                        continue;
11206                    };
11207                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
11208                    delete_by_key
11209                        .entry((*table_id, pk_value.encode_key()))
11210                        .or_default()
11211                        .push_back(idx);
11212                }
11213                Staged::Put(cells) => {
11214                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
11215                        put_by_key
11216                            .entry((*table_id, value.encode_key()))
11217                            .or_default()
11218                            .push_back(idx);
11219                    }
11220                }
11221                Staged::Update { row_id, .. } => {
11222                    let handle = self.table_by_id(*table_id)?;
11223                    let row = handle.lock().get(*row_id, snapshot);
11224                    if let Some(row) = row {
11225                        old_rows.insert(idx, TriggerRowImage::from_row(row));
11226                    }
11227                }
11228                Staged::Truncate => {}
11229            }
11230        }
11231
11232        let mut paired_delete = std::collections::HashSet::new();
11233        let mut paired_put = std::collections::HashSet::new();
11234        let mut events = Vec::new();
11235
11236        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
11237            commit_prepare_checkpoint(control, pair_index)?;
11238            let Some(puts) = put_by_key.get_mut(key) else {
11239                continue;
11240            };
11241            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
11242                paired_delete.insert(delete_idx);
11243                paired_put.insert(put_idx);
11244                let (table_id, _) = &staging[put_idx];
11245                let Some(table_name) = table_names.get(table_id).cloned() else {
11246                    continue;
11247                };
11248                let old = old_rows.get(&delete_idx).cloned();
11249                let new = match &staging[put_idx].1 {
11250                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
11251                    _ => None,
11252                };
11253                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11254                events.push(WriteEvent {
11255                    table: table_name,
11256                    kind: TriggerEvent::Update,
11257                    old,
11258                    new,
11259                    changed_columns,
11260                    op_indices: vec![delete_idx, put_idx],
11261                    put_idx: Some(put_idx),
11262                    trigger_stack: Self::trigger_stack_for_indices(
11263                        trigger_stacks,
11264                        &[delete_idx, put_idx],
11265                    ),
11266                });
11267            }
11268        }
11269
11270        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11271            commit_prepare_checkpoint(control, idx)?;
11272            let Some(table_name) = table_names.get(table_id).cloned() else {
11273                continue;
11274            };
11275            match staged {
11276                Staged::Put(cells) if !paired_put.contains(&idx) => {
11277                    let new = Some(TriggerRowImage::from_cells(cells));
11278                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
11279                    events.push(WriteEvent {
11280                        table: table_name,
11281                        kind: TriggerEvent::Insert,
11282                        old: None,
11283                        new,
11284                        changed_columns,
11285                        op_indices: vec![idx],
11286                        put_idx: Some(idx),
11287                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11288                    });
11289                }
11290                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
11291                    let old = match old_rows.get(&idx).cloned() {
11292                        Some(old) => Some(old),
11293                        None => {
11294                            let handle = self.table_by_id(*table_id)?;
11295                            let row = handle.lock().get(*row_id, snapshot);
11296                            row.map(TriggerRowImage::from_row)
11297                        }
11298                    };
11299                    let Some(old) = old else {
11300                        continue;
11301                    };
11302                    let changed_columns = old.columns.keys().copied().collect();
11303                    events.push(WriteEvent {
11304                        table: table_name,
11305                        kind: TriggerEvent::Delete,
11306                        old: Some(old),
11307                        new: None,
11308                        changed_columns,
11309                        op_indices: vec![idx],
11310                        put_idx: None,
11311                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11312                    });
11313                }
11314                Staged::Update { new_row: cells, .. } => {
11315                    let old = old_rows.get(&idx).cloned();
11316                    let new = Some(TriggerRowImage::from_cells(cells));
11317                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11318                    events.push(WriteEvent {
11319                        table: table_name,
11320                        kind: TriggerEvent::Update,
11321                        old,
11322                        new,
11323                        changed_columns,
11324                        op_indices: vec![idx],
11325                        put_idx: Some(idx),
11326                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11327                    });
11328                }
11329                Staged::Truncate => {}
11330                _ => {}
11331            }
11332        }
11333
11334        Ok(events)
11335    }
11336
11337    #[allow(clippy::too_many_arguments)]
11338    fn execute_trigger_program(
11339        &self,
11340        trigger: &StoredTrigger,
11341        event: &WriteEvent,
11342        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11343        out: &mut TriggerProgramOutput<'_>,
11344        trigger_stack: &[String],
11345        config: &TriggerConfig,
11346        read_epoch: Epoch,
11347        control: Option<&crate::ExecutionControl>,
11348    ) -> Result<TriggerProgramOutcome> {
11349        let mut event = event.clone();
11350        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
11351        self.execute_trigger_steps(
11352            trigger,
11353            &trigger.program.steps,
11354            &mut event,
11355            staging,
11356            out,
11357            trigger_stack,
11358            config,
11359            &mut select_results,
11360            0,
11361            None,
11362            read_epoch,
11363            control,
11364        )
11365    }
11366
11367    #[allow(clippy::too_many_arguments)]
11368    fn execute_trigger_steps(
11369        &self,
11370        trigger: &StoredTrigger,
11371        steps: &[TriggerStep],
11372        event: &mut WriteEvent,
11373        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11374        out: &mut TriggerProgramOutput<'_>,
11375        trigger_stack: &[String],
11376        config: &TriggerConfig,
11377        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
11378        depth: u32,
11379        selected: Option<&TriggerRowImage>,
11380        read_epoch: Epoch,
11381        control: Option<&crate::ExecutionControl>,
11382    ) -> Result<TriggerProgramOutcome> {
11383        let _ = depth;
11384        for (step_index, step) in steps.iter().enumerate() {
11385            commit_prepare_checkpoint(control, step_index)?;
11386            match step {
11387                TriggerStep::SetNew { cells } => {
11388                    if trigger.timing != TriggerTiming::Before {
11389                        return Err(MongrelError::InvalidArgument(
11390                            "SetNew trigger step is only valid in BEFORE triggers".into(),
11391                        ));
11392                    }
11393                    let put_idx = event.put_idx.ok_or_else(|| {
11394                        MongrelError::InvalidArgument(
11395                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
11396                        )
11397                    })?;
11398                    let staging = staging.as_deref_mut().ok_or_else(|| {
11399                        MongrelError::InvalidArgument(
11400                            "SetNew trigger step requires mutable trigger staging".into(),
11401                        )
11402                    })?;
11403                    let mut update_changed_columns = None;
11404                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
11405                        Some(crate::txn::Staged::Put(cells)) => cells,
11406                        Some(crate::txn::Staged::Update {
11407                            new_row,
11408                            changed_columns,
11409                            ..
11410                        }) => {
11411                            update_changed_columns = Some(changed_columns);
11412                            new_row
11413                        }
11414                        _ => {
11415                            return Err(MongrelError::InvalidArgument(
11416                                "SetNew trigger step target row is not mutable".into(),
11417                            ))
11418                        }
11419                    };
11420                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
11421                        row_cells.retain(|(id, _)| *id != column_id);
11422                        row_cells.push((column_id, value.clone()));
11423                        if let Some(changed_columns) = &mut update_changed_columns {
11424                            changed_columns.push(column_id);
11425                        }
11426                        if let Some(new) = &mut event.new {
11427                            new.columns.insert(column_id, value);
11428                        }
11429                    }
11430                    row_cells.sort_by_key(|(id, _)| *id);
11431                    if let Some(changed_columns) = update_changed_columns {
11432                        changed_columns.sort_unstable();
11433                        changed_columns.dedup();
11434                    }
11435                }
11436                TriggerStep::Insert { table, cells } => {
11437                    let cells = eval_trigger_cells(cells, event, selected)?;
11438                    if let Ok(table_id) = self.table_id(table) {
11439                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
11440                        out.added_stacks.push(trigger_stack.to_vec());
11441                    } else if self.external_table(table).is_some() {
11442                        out.added_external.push(ExternalTriggerWrite::Insert {
11443                            table: table.clone(),
11444                            cells,
11445                        });
11446                    } else {
11447                        return Err(MongrelError::NotFound(format!(
11448                            "trigger {:?} insert target {table:?} not found",
11449                            trigger.name
11450                        )));
11451                    }
11452                }
11453                TriggerStep::UpdateByPk { table, pk, cells } => {
11454                    let pk = eval_trigger_value(pk, event, selected)?;
11455                    let cells = eval_trigger_cells(cells, event, selected)?;
11456                    if self.external_table(table).is_some() {
11457                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
11458                            table: table.clone(),
11459                            pk,
11460                            cells,
11461                        });
11462                    } else {
11463                        let row_id = self
11464                            .table(table)?
11465                            .lock()
11466                            .lookup_pk(&pk.encode_key())
11467                            .ok_or_else(|| {
11468                                MongrelError::NotFound(format!(
11469                                    "trigger {:?} update target not found",
11470                                    trigger.name
11471                                ))
11472                            })?;
11473                        let handle = self.table(table)?;
11474                        let snapshot = self.snapshot_for_epoch(self.epoch.visible());
11475                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
11476                            MongrelError::NotFound(format!(
11477                                "trigger {:?} update target not visible",
11478                                trigger.name
11479                            ))
11480                        })?;
11481                        let mut changed_columns = cells
11482                            .iter()
11483                            .map(|(column_id, _)| *column_id)
11484                            .collect::<Vec<_>>();
11485                        changed_columns.sort_unstable();
11486                        changed_columns.dedup();
11487                        let mut merged = old.columns;
11488                        for (column_id, value) in cells {
11489                            merged.insert(column_id, value);
11490                        }
11491                        out.added.push((
11492                            self.table_id(table)?,
11493                            crate::txn::Staged::Update {
11494                                row_id,
11495                                new_row: merged.into_iter().collect(),
11496                                changed_columns,
11497                            },
11498                        ));
11499                        out.added_stacks.push(trigger_stack.to_vec());
11500                    }
11501                }
11502                TriggerStep::DeleteByPk { table, pk } => {
11503                    let pk = eval_trigger_value(pk, event, selected)?;
11504                    if self.external_table(table).is_some() {
11505                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
11506                            table: table.clone(),
11507                            pk,
11508                        });
11509                    } else {
11510                        let row_id = self
11511                            .table(table)?
11512                            .lock()
11513                            .lookup_pk(&pk.encode_key())
11514                            .ok_or_else(|| {
11515                                MongrelError::NotFound(format!(
11516                                    "trigger {:?} delete target not found",
11517                                    trigger.name
11518                                ))
11519                            })?;
11520                        out.added
11521                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
11522                        out.added_stacks.push(trigger_stack.to_vec());
11523                    }
11524                }
11525                TriggerStep::Select {
11526                    id,
11527                    table,
11528                    conditions,
11529                } => {
11530                    let schema = self.table(table)?.lock().schema().clone();
11531                    let snapshot = self.snapshot_for_epoch(read_epoch);
11532                    let handle = self.table(table)?;
11533                    let rows = match control {
11534                        Some(control) => {
11535                            handle.lock().visible_rows_controlled(snapshot, control)?
11536                        }
11537                        None => handle.lock().visible_rows(snapshot)?,
11538                    };
11539                    let mut matched = Vec::new();
11540                    for (row_index, row) in rows.into_iter().enumerate() {
11541                        commit_prepare_checkpoint(control, row_index)?;
11542                        let image = TriggerRowImage::from_row(row);
11543                        let passes = conditions
11544                            .iter()
11545                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11546                            .collect::<Result<Vec<_>>>()?
11547                            .into_iter()
11548                            .all(|b| b);
11549                        if passes {
11550                            matched.push(image);
11551                        }
11552                    }
11553                    if let Some(pk) = schema.primary_key() {
11554                        matched.sort_by(|a, b| {
11555                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
11556                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
11557                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
11558                        });
11559                    }
11560                    select_results.insert(id.clone(), matched);
11561                }
11562                TriggerStep::Foreach { id, steps } => {
11563                    let rows = select_results.get(id).ok_or_else(|| {
11564                        MongrelError::InvalidArgument(format!(
11565                            "trigger {:?} foreach references unknown select id {id:?}",
11566                            trigger.name
11567                        ))
11568                    })?;
11569                    if rows.len() > config.max_loop_iterations as usize {
11570                        return Err(MongrelError::InvalidArgument(format!(
11571                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
11572                            trigger.name, config.max_loop_iterations
11573                        )));
11574                    }
11575                    for (row_index, row) in rows.clone().into_iter().enumerate() {
11576                        commit_prepare_checkpoint(control, row_index)?;
11577                        let result = self.execute_trigger_steps(
11578                            trigger,
11579                            steps,
11580                            event,
11581                            staging.as_deref_mut(),
11582                            out,
11583                            trigger_stack,
11584                            config,
11585                            select_results,
11586                            depth + 1,
11587                            Some(&row),
11588                            read_epoch,
11589                            control,
11590                        )?;
11591                        if result == TriggerProgramOutcome::Ignore {
11592                            return Ok(TriggerProgramOutcome::Ignore);
11593                        }
11594                    }
11595                }
11596                TriggerStep::DeleteWhere { table, conditions } => {
11597                    let schema = self.table(table)?.lock().schema().clone();
11598                    let snapshot = self.snapshot_for_epoch(read_epoch);
11599                    let handle = self.table(table)?;
11600                    let rows = match control {
11601                        Some(control) => {
11602                            handle.lock().visible_rows_controlled(snapshot, control)?
11603                        }
11604                        None => handle.lock().visible_rows(snapshot)?,
11605                    };
11606                    let table_id = self.table_id(table)?;
11607                    let mut to_delete = Vec::new();
11608                    for (row_index, row) in rows.into_iter().enumerate() {
11609                        commit_prepare_checkpoint(control, row_index)?;
11610                        let image = TriggerRowImage::from_row(row.clone());
11611                        let passes = conditions
11612                            .iter()
11613                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11614                            .collect::<Result<Vec<_>>>()?
11615                            .into_iter()
11616                            .all(|b| b);
11617                        if passes {
11618                            to_delete.push((table_id, row.row_id));
11619                        }
11620                    }
11621                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
11622                        commit_prepare_checkpoint(control, row_index)?;
11623                        out.added
11624                            .push((table_id, crate::txn::Staged::Delete(row_id)));
11625                        out.added_stacks.push(trigger_stack.to_vec());
11626                    }
11627                }
11628                TriggerStep::UpdateWhere {
11629                    table,
11630                    conditions,
11631                    cells,
11632                } => {
11633                    let schema = self.table(table)?.lock().schema().clone();
11634                    let snapshot = self.snapshot_for_epoch(read_epoch);
11635                    let handle = self.table(table)?;
11636                    let rows = match control {
11637                        Some(control) => {
11638                            handle.lock().visible_rows_controlled(snapshot, control)?
11639                        }
11640                        None => handle.lock().visible_rows(snapshot)?,
11641                    };
11642                    let table_id = self.table_id(table)?;
11643                    let mut changed_columns =
11644                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
11645                    changed_columns.sort_unstable();
11646                    changed_columns.dedup();
11647                    let mut to_update = Vec::new();
11648                    for (row_index, row) in rows.into_iter().enumerate() {
11649                        commit_prepare_checkpoint(control, row_index)?;
11650                        let image = TriggerRowImage::from_row(row.clone());
11651                        let passes = conditions
11652                            .iter()
11653                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11654                            .collect::<Result<Vec<_>>>()?
11655                            .into_iter()
11656                            .all(|b| b);
11657                        if passes {
11658                            let new_cells = cells
11659                                .iter()
11660                                .map(|cell| {
11661                                    Ok((
11662                                        cell.column_id,
11663                                        eval_trigger_value(&cell.value, event, Some(&image))?,
11664                                    ))
11665                                })
11666                                .collect::<Result<Vec<_>>>()?;
11667                            let mut merged = row.columns.clone();
11668                            for (column_id, value) in new_cells {
11669                                merged.insert(column_id, value);
11670                            }
11671                            to_update.push((table_id, row.row_id, merged));
11672                        }
11673                    }
11674                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
11675                    {
11676                        commit_prepare_checkpoint(control, row_index)?;
11677                        out.added.push((
11678                            table_id,
11679                            crate::txn::Staged::Update {
11680                                row_id,
11681                                new_row: merged.into_iter().collect(),
11682                                changed_columns: changed_columns.clone(),
11683                            },
11684                        ));
11685                        out.added_stacks.push(trigger_stack.to_vec());
11686                    }
11687                }
11688                TriggerStep::Raise { action, message } => match action {
11689                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
11690                    TriggerRaiseAction::Abort
11691                    | TriggerRaiseAction::Fail
11692                    | TriggerRaiseAction::Rollback => {
11693                        let message = eval_trigger_value(message, event, selected)?;
11694                        return Err(MongrelError::TriggerValidation(format!(
11695                            "trigger {:?} raised: {}; trigger stack: {}",
11696                            trigger.name,
11697                            trigger_message(message),
11698                            Self::format_trigger_stack(trigger_stack)
11699                        )));
11700                    }
11701                },
11702            }
11703        }
11704        Ok(TriggerProgramOutcome::Continue)
11705    }
11706
11707    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
11708        let Some(stacks) = stacks else {
11709            return Vec::new();
11710        };
11711        let mut out = Vec::new();
11712        for idx in indices {
11713            let Some(stack) = stacks.get(*idx) else {
11714                continue;
11715            };
11716            for name in stack {
11717                if !out.iter().any(|existing| existing == name) {
11718                    out.push(name.clone());
11719                }
11720            }
11721        }
11722        out
11723    }
11724
11725    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
11726        let mut out = stack.to_vec();
11727        out.push(trigger_name.to_string());
11728        out
11729    }
11730
11731    fn format_trigger_stack(stack: &[String]) -> String {
11732        if stack.is_empty() {
11733            "<root>".into()
11734        } else {
11735            stack.join(" -> ")
11736        }
11737    }
11738
11739    /// Authoritatively validate every declared constraint on the staged write
11740    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
11741    /// SET NULL actions into explicit child ops. Called from
11742    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
11743    /// violation as an `Err`, aborting the commit atomically. This is the
11744    /// server-side authority point: concurrent remote writers that each pass
11745    /// their own client-side checks still cannot both commit a violating batch.
11746    ///
11747    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
11748    /// intra-transaction dedup; concurrent-txn races are additionally caught by
11749    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
11750    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
11751    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
11752    /// RESTRICT-only (cascade-truncate is unsupported).
11753    /// S1B-003: acquire Exclusive key claims for every primary key and declared
11754    /// UNIQUE key a transaction's staged puts/updates insert, in ascending key
11755    /// order (ordered acquisition cannot cycle on its own). A concurrent
11756    /// transaction claiming the same key blocks until this one ends, turning
11757    /// the optimistic write-write conflict into a serialization point. Claims
11758    /// release with the transaction's [`TxnLockGuard`].
11759    fn acquire_unique_key_claims(
11760        &self,
11761        txn_id: u64,
11762        staging: &[(u64, crate::txn::Staged)],
11763        control: Option<&crate::ExecutionControl>,
11764    ) -> Result<()> {
11765        let catalog = self.catalog.read();
11766        let has_uniques = staging.iter().any(|(table_id, staged)| {
11767            matches!(
11768                staged,
11769                crate::txn::Staged::Put(_) | crate::txn::Staged::Update { .. }
11770            ) && catalog.tables.iter().any(|entry| {
11771                entry.table_id == *table_id
11772                    && (entry.schema.primary_key().is_some()
11773                        || !entry.schema.constraints.uniques.is_empty())
11774            })
11775        });
11776        if !has_uniques {
11777            return Ok(());
11778        }
11779        let mut claims: Vec<(u64, Vec<u8>)> = Vec::new();
11780        for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11781            commit_prepare_checkpoint(control, staged_index)?;
11782            let cells = match staged {
11783                crate::txn::Staged::Put(cells) => cells,
11784                crate::txn::Staged::Update { new_row, .. } => new_row,
11785                _ => continue,
11786            };
11787            let Some(entry) = catalog
11788                .tables
11789                .iter()
11790                .find(|entry| entry.table_id == *table_id)
11791            else {
11792                continue;
11793            };
11794            for column in &entry.schema.columns {
11795                if !column
11796                    .flags
11797                    .contains(crate::schema::ColumnFlags::PRIMARY_KEY)
11798                {
11799                    continue;
11800                }
11801                if let Some((_, value)) = cells.iter().find(|(id, _)| *id == column.id) {
11802                    let mut key = b"pk:".to_vec();
11803                    key.extend_from_slice(&value.encode_key());
11804                    claims.push((*table_id, key));
11805                }
11806            }
11807            // Declared non-PK unique constraints claim their own namespace
11808            // (folding the constraint id into the key, per LockKey::Key's
11809            // multi-key-space rule). NULL components skip the constraint —
11810            // and the claim — per SQL semantics.
11811            let cells_map: HashMap<u16, Value> = cells.iter().cloned().collect();
11812            for uc in &entry.schema.constraints.uniques {
11813                if let Some(composite) =
11814                    crate::constraint::encode_composite_key(&uc.columns, &cells_map)
11815                {
11816                    let mut key = format!("uq{}:", uc.id).into_bytes();
11817                    key.extend_from_slice(&composite);
11818                    claims.push((*table_id, key));
11819                }
11820            }
11821        }
11822        claims.sort();
11823        claims.dedup();
11824        for (table_id, key) in claims {
11825            self.acquire_txn_lock(
11826                txn_id,
11827                crate::locks::LockKey::key(table_id, key),
11828                crate::locks::LockMode::Exclusive,
11829                control,
11830            )?;
11831        }
11832        Ok(())
11833    }
11834
11835    /// S1B-003: one FK parent-protection acquisition. `Err` propagates the
11836    /// deadlock/deadline/cancellation outcome; the test seam fires after each
11837    /// successful acquisition.
11838    fn acquire_fk_lock(
11839        &self,
11840        txn_id: u64,
11841        table_id: u64,
11842        key: &[u8],
11843        mode: crate::locks::LockMode,
11844        control: Option<&crate::ExecutionControl>,
11845    ) -> Result<()> {
11846        let mut namespaced = b"fk:".to_vec();
11847        namespaced.extend_from_slice(key);
11848        self.acquire_txn_lock(
11849            txn_id,
11850            crate::locks::LockKey::key(table_id, namespaced),
11851            mode,
11852            control,
11853        )?;
11854        // The hook is cloned out before firing: holding the slot's mutex while
11855        // a parked hook waits would block every other commit's hook call.
11856        let hook = self.fk_lock_hook.lock().clone();
11857        if let Some(hook) = hook {
11858            hook();
11859        }
11860        Ok(())
11861    }
11862
11863    fn validate_constraints(
11864        &self,
11865        txn_id: u64,
11866        staging: &mut Vec<(u64, crate::txn::Staged)>,
11867        read_epoch: Epoch,
11868        principal: Option<&crate::auth::Principal>,
11869        control: Option<&crate::ExecutionControl>,
11870    ) -> Result<()> {
11871        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
11872        use crate::memtable::Row;
11873        use crate::txn::Staged;
11874        use std::collections::HashSet;
11875
11876        commit_prepare_checkpoint(control, 0)?;
11877        // P0.5: constraint checks must use an HLC-pinned snapshot so parent /
11878        // unique rows stamped with commit_ts remain visible.
11879        let snapshot = self.snapshot_for_epoch(read_epoch);
11880        // Check the no-constraint fast path while borrowing the catalog. The
11881        // previous path cloned the complete catalog and every live schema before
11882        // discovering there was no constraint work to do.
11883        let (live, table_ids_by_name) = {
11884            let catalog = self.catalog.read();
11885            let any_constraints = catalog.tables.iter().any(|entry| {
11886                matches!(entry.state, TableState::Live | TableState::Building { .. })
11887                    && !entry.schema.constraints.is_empty()
11888            });
11889            if !any_constraints {
11890                drop(catalog);
11891                self.materialize_generated_embeddings(staging, control)?;
11892                return Ok(());
11893            }
11894            let live: Vec<(u64, String, crate::schema::Schema)> = catalog
11895                .tables
11896                .iter()
11897                .filter(|entry| {
11898                    matches!(entry.state, TableState::Live | TableState::Building { .. })
11899                })
11900                .map(|entry| (entry.table_id, entry.name.clone(), entry.schema.clone()))
11901                .collect();
11902            let table_ids_by_name: HashMap<String, u64> = catalog
11903                .tables
11904                .iter()
11905                .map(|entry| (entry.name.clone(), entry.table_id))
11906                .collect();
11907            (live, table_ids_by_name)
11908        };
11909
11910        // Lazily-loaded visible rows per table, shared across checks. Cache hits
11911        // clone only the `Arc`, not every row and its column values.
11912        let mut rows_cache: HashMap<u64, Arc<Vec<Row>>> = HashMap::new();
11913        let mut load_rows = |table_id: u64| -> Result<Arc<Vec<Row>>> {
11914            if let Some(rows) = rows_cache.get(&table_id) {
11915                return Ok(Arc::clone(rows));
11916            }
11917            let handle = self.table_by_id(table_id)?;
11918            let rows = Arc::new(match control {
11919                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
11920                None => handle.lock().visible_rows(snapshot)?,
11921            });
11922            rows_cache.insert(table_id, Arc::clone(&rows));
11923            Ok(rows)
11924        };
11925
11926        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
11927        // carry an explicit old RowId + full new image. This makes action choice
11928        // reliable even when the referenced key itself changes; a delete+put
11929        // heuristic cannot distinguish that from unrelated operations.
11930        let mut processed_updates = HashSet::new();
11931        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
11932        let mut update_pass = 0_usize;
11933        loop {
11934            commit_prepare_checkpoint(control, update_pass)?;
11935            update_pass += 1;
11936            let updates: Vec<PendingUpdate> = staging
11937                .iter()
11938                .enumerate()
11939                .filter_map(|(index, (table_id, op))| match op {
11940                    Staged::Update {
11941                        row_id,
11942                        new_row: cells,
11943                        ..
11944                    } if !processed_updates.contains(&index) => {
11945                        Some((index, *table_id, *row_id, cells.clone()))
11946                    }
11947                    _ => None,
11948                })
11949                .collect();
11950            if updates.is_empty() {
11951                break;
11952            }
11953            let mut new_ops = Vec::new();
11954            for (update_index, (index, table_id, row_id, new_cells)) in
11955                updates.into_iter().enumerate()
11956            {
11957                commit_prepare_checkpoint(control, update_index)?;
11958                processed_updates.insert(index);
11959                let Some(tname) = live
11960                    .iter()
11961                    .find(|(id, _, _)| *id == table_id)
11962                    .map(|(_, name, _)| name.as_str())
11963                else {
11964                    continue;
11965                };
11966                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
11967                    continue;
11968                };
11969                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
11970                for (child_id, _child_name, child_schema) in &live {
11971                    for fk in &child_schema.constraints.foreign_keys {
11972                        if fk.ref_table != tname {
11973                            continue;
11974                        }
11975                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
11976                        else {
11977                            continue;
11978                        };
11979                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
11980                            == Some(old_key.as_slice())
11981                        {
11982                            continue;
11983                        }
11984                        if fk.on_update == FkAction::Restrict {
11985                            continue;
11986                        }
11987                        // S1B-003: the referenced key is being changed, so this
11988                        // update removes it for any action — hold an Exclusive
11989                        // parent-protection lock against concurrent child
11990                        // inserts referencing the old key.
11991                        self.acquire_fk_lock(
11992                            txn_id,
11993                            table_id,
11994                            &old_key,
11995                            crate::locks::LockMode::Exclusive,
11996                            control,
11997                        )?;
11998                        let child_rows = load_rows(*child_id)?;
11999                        for (child_index, child) in child_rows.iter().enumerate() {
12000                            commit_prepare_checkpoint(control, child_index)?;
12001                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
12002                                != Some(old_key.as_slice())
12003                            {
12004                                continue;
12005                            }
12006                            if staging.iter().any(|(id, op)| {
12007                                *id == *child_id
12008                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
12009                            }) {
12010                                continue;
12011                            }
12012                            let mut cells: Vec<(u16, Value)> = child
12013                                .columns
12014                                .iter()
12015                                .map(|(column_id, value)| (*column_id, value.clone()))
12016                                .collect();
12017                            for (child_column, parent_column) in
12018                                fk.columns.iter().zip(&fk.ref_columns)
12019                            {
12020                                cells.retain(|(column_id, _)| column_id != child_column);
12021                                let value = match fk.on_update {
12022                                    FkAction::Cascade => {
12023                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
12024                                    }
12025                                    FkAction::SetNull => Value::Null,
12026                                    FkAction::Restrict => {
12027                                        return Err(MongrelError::Other(
12028                                            "restricted foreign-key update reached cascade preparation"
12029                                                .into(),
12030                                        ));
12031                                    }
12032                                };
12033                                cells.push((*child_column, value));
12034                            }
12035                            cells.sort_by_key(|(column_id, _)| *column_id);
12036                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
12037                                *id == *child_id
12038                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
12039                            }) {
12040                                if let Staged::Update {
12041                                    new_row: existing,
12042                                    changed_columns,
12043                                    ..
12044                                } = &mut staging[existing_index].1 {
12045                                    changed_columns.extend(fk.columns.iter().copied());
12046                                    changed_columns.sort_unstable();
12047                                    changed_columns.dedup();
12048                                    if *existing != cells {
12049                                        *existing = cells;
12050                                        processed_updates.remove(&existing_index);
12051                                    }
12052                                }
12053                            } else {
12054                                new_ops.push((
12055                                    *child_id,
12056                                    Staged::Update {
12057                                        row_id: child.row_id,
12058                                        new_row: cells,
12059                                        changed_columns: fk.columns.clone(),
12060                                    },
12061                                ));
12062                            }
12063                        }
12064                    }
12065                }
12066            }
12067            staging.extend(new_ops);
12068        }
12069
12070        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
12071        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
12072        // enforced as a violation in Phase B. `cascaded` records every delete
12073        // we have already expanded so a self-referential CASCADE FK cannot loop.
12074        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
12075        let mut cascade_pass = 0_usize;
12076        loop {
12077            commit_prepare_checkpoint(control, cascade_pass)?;
12078            cascade_pass += 1;
12079            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
12080            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
12081                .iter()
12082                .filter_map(|(t, op)| match op {
12083                    Staged::Delete(rid) => Some((*t, *rid)),
12084                    _ => None,
12085                })
12086                .collect();
12087            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
12088                commit_prepare_checkpoint(control, delete_index)?;
12089                if !cascaded.insert((table_id, rid.0)) {
12090                    continue;
12091                }
12092                let Some(tname) = live
12093                    .iter()
12094                    .find(|(t, _, _)| *t == table_id)
12095                    .map(|(_, n, _)| n.as_str())
12096                else {
12097                    continue;
12098                };
12099                let parent_handle = self.table_by_id(table_id)?;
12100                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
12101                    continue;
12102                };
12103                for (child_id, _child_name, child_schema) in &live {
12104                    for fk in &child_schema.constraints.foreign_keys {
12105                        if fk.ref_table != tname {
12106                            continue;
12107                        }
12108                        let Some(parent_key) =
12109                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
12110                        else {
12111                            continue;
12112                        };
12113                        // Suppress ON DELETE cascade/set-null when this "delete"
12114                        // is actually half of an UPDATE encoded as Delete(old)+
12115                        // Put(new): if a staged Put in the SAME table still
12116                        // provides the referenced parent key, the parent still
12117                        // exists (its non-key columns changed) and the children
12118                        // must be left alone. A genuine delete, or an update
12119                        // that CHANGES the referenced key, has no preserving Put
12120                        // → cascade fires as before.
12121                        let key_preserved = staging.iter().any(|(t, op)| {
12122                            if *t != table_id {
12123                                return false;
12124                            }
12125                            let Staged::Put(cells) = op else {
12126                                return false;
12127                            };
12128                            let map: HashMap<u16, crate::memtable::Value> =
12129                                cells.iter().cloned().collect();
12130                            encode_composite_key(&fk.ref_columns, &map).as_deref()
12131                                == Some(parent_key.as_slice())
12132                        });
12133                        if key_preserved {
12134                            continue;
12135                        }
12136                        // S1B-003: the referenced parent key is genuinely being
12137                        // removed (delete, or the delete half of a key-changing
12138                        // update), for every FK action — hold an Exclusive
12139                        // parent-protection lock against concurrent child
12140                        // inserts referencing it. RESTRICT fks are enforced in
12141                        // Phase B; the claim covers them too.
12142                        self.acquire_fk_lock(
12143                            txn_id,
12144                            table_id,
12145                            &parent_key,
12146                            crate::locks::LockMode::Exclusive,
12147                            control,
12148                        )?;
12149                        match fk.on_delete {
12150                            FkAction::Restrict => continue,
12151                            FkAction::Cascade => {
12152                                let child_rows = load_rows(*child_id)?;
12153                                for (child_index, cr) in child_rows.iter().enumerate() {
12154                                    commit_prepare_checkpoint(control, child_index)?;
12155                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12156                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12157                                            == Some(parent_key.as_slice())
12158                                    {
12159                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
12160                                    }
12161                                }
12162                            }
12163                            FkAction::SetNull => {
12164                                let child_rows = load_rows(*child_id)?;
12165                                for (child_index, cr) in child_rows.iter().enumerate() {
12166                                    commit_prepare_checkpoint(control, child_index)?;
12167                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12168                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12169                                            == Some(parent_key.as_slice())
12170                                    {
12171                                        // Re-emit the child row with the FK
12172                                        // columns set to NULL (delete + put).
12173                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
12174                                            .columns
12175                                            .iter()
12176                                            .map(|(k, v)| (*k, v.clone()))
12177                                            .collect();
12178                                        for cid in &fk.columns {
12179                                            cells.retain(|(k, _)| k != cid);
12180                                            cells.push((*cid, crate::memtable::Value::Null));
12181                                        }
12182                                        new_ops.push((
12183                                            *child_id,
12184                                            Staged::Update {
12185                                                row_id: cr.row_id,
12186                                                new_row: cells,
12187                                                changed_columns: fk.columns.clone(),
12188                                            },
12189                                        ));
12190                                    }
12191                                }
12192                            }
12193                        }
12194                    }
12195                }
12196            }
12197            if new_ops.is_empty() {
12198                break;
12199            }
12200            staging.extend(new_ops);
12201        }
12202
12203        // Constraint actions can add writes. Re-authorize the final write set
12204        // before generated-value providers receive any row content, then
12205        // materialize vectors before Phase B validates the committed cells.
12206        self.validate_write_permissions(staging, principal, control)?;
12207        self.validate_security_writes(staging, read_epoch, principal, control)?;
12208        self.materialize_generated_embeddings(staging, control)?;
12209
12210        // Rows staged for deletion in THIS transaction (now including cascaded
12211        // deletes). Used to exclude the old version of an updated row from
12212        // unique-existence scans.
12213        let staged_deletes: HashSet<(u64, u64)> = staging
12214            .iter()
12215            .filter_map(|(t, op)| match op {
12216                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
12217                _ => None,
12218            })
12219            .collect();
12220
12221        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
12222        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
12223
12224        // ── Phase B: validate the fully-expanded staging set.
12225        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
12226            commit_prepare_checkpoint(control, operation_index)?;
12227            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id) else {
12228                continue;
12229            };
12230            let cells_map: HashMap<u16, crate::memtable::Value>;
12231            match op {
12232                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
12233                    cells_map = cells.iter().cloned().collect();
12234
12235                    // CHECK constraints.
12236                    if !schema.constraints.checks.is_empty() {
12237                        validate_checks(&schema.constraints.checks, &cells_map)?;
12238                    }
12239
12240                    // UNIQUE (non-PK) constraints.
12241                    for uc in &schema.constraints.uniques {
12242                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
12243                            continue; // NULL in a constrained column → skip (SQL).
12244                        };
12245                        let marker = (*table_id, uc.id, key.clone());
12246                        if !seen_unique.insert(marker) {
12247                            return Err(MongrelError::Conflict(format!(
12248                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
12249                                uc.name
12250                            )));
12251                        }
12252                        let rows = load_rows(*table_id)?;
12253                        for (row_index, r) in rows.iter().enumerate() {
12254                            commit_prepare_checkpoint(control, row_index)?;
12255                            // Skip rows this same transaction is deleting (the
12256                            // old version of an updated/cascade-deleted row).
12257                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
12258                                continue;
12259                            }
12260                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
12261                                if theirs == key {
12262                                    return Err(MongrelError::Conflict(format!(
12263                                        "UNIQUE constraint '{}' on table '{tname}' violated",
12264                                        uc.name
12265                                    )));
12266                                }
12267                            }
12268                        }
12269                    }
12270
12271                    // FK insert-side: parent must exist.
12272                    for fk in &schema.constraints.foreign_keys {
12273                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
12274                            continue; // NULL FK component → not checked (SQL).
12275                        };
12276                        let Some(parent_id) = table_ids_by_name.get(&fk.ref_table).copied() else {
12277                            return Err(MongrelError::InvalidArgument(format!(
12278                                "FOREIGN KEY '{}' references unknown table '{}'",
12279                                fk.name, fk.ref_table
12280                            )));
12281                        };
12282                        // S1B-003: hold a Shared parent-protection lock on the
12283                        // referenced key while checking existence, so a
12284                        // concurrent parent delete or key-changing update
12285                        // serializes against this insert.
12286                        self.acquire_fk_lock(
12287                            txn_id,
12288                            parent_id,
12289                            &child_key,
12290                            crate::locks::LockMode::Shared,
12291                            control,
12292                        )?;
12293                        let parent_rows = load_rows(parent_id)?;
12294                        let mut found = false;
12295                        for (row_index, r) in parent_rows.iter().enumerate() {
12296                            commit_prepare_checkpoint(control, row_index)?;
12297                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
12298                                continue;
12299                            }
12300                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
12301                                if pkey == child_key {
12302                                    found = true;
12303                                    break;
12304                                }
12305                            }
12306                        }
12307                        // Final-write-set FK validation: a parent inserted in
12308                        // THIS transaction also satisfies the FK. This enables
12309                        // atomic parent+child batches and cyclical/mutual FK
12310                        // inserts within a single transaction — the child sees
12311                        // the staged parent put even though it is not committed
12312                        // yet.
12313                        if !found {
12314                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
12315                                commit_prepare_checkpoint(control, staged_index)?;
12316                                if *st_table != parent_id {
12317                                    continue;
12318                                }
12319                                if let Staged::Put(pcells)
12320                                | Staged::Update {
12321                                    new_row: pcells, ..
12322                                } = st_op
12323                                {
12324                                    let pmap: HashMap<u16, crate::memtable::Value> =
12325                                        pcells.iter().cloned().collect();
12326                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
12327                                    {
12328                                        if pkey == child_key {
12329                                            found = true;
12330                                            break;
12331                                        }
12332                                    }
12333                                }
12334                            }
12335                        }
12336                        if !found {
12337                            return Err(MongrelError::Conflict(format!(
12338                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
12339                                fk.name, fk.ref_table
12340                            )));
12341                        }
12342                    }
12343
12344                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
12345                    // expanded in Phase A; here the final child write set is
12346                    // known, so a child explicitly moved/deleted by this same
12347                    // transaction does not cause a false violation.
12348                    if let Staged::Update { row_id, .. } = op {
12349                        let parent_handle = self.table_by_id(*table_id)?;
12350                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
12351                            continue;
12352                        };
12353                        for (child_id, child_name, child_schema) in &live {
12354                            for fk in &child_schema.constraints.foreign_keys {
12355                                if fk.ref_table != *tname || fk.on_update != FkAction::Restrict {
12356                                    continue;
12357                                }
12358                                let Some(old_key) =
12359                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
12360                                else {
12361                                    continue;
12362                                };
12363                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
12364                                    == Some(old_key.as_slice())
12365                                {
12366                                    continue;
12367                                }
12368                                // S1B-003: the referenced key is being changed —
12369                                // hold an Exclusive parent-protection lock
12370                                // against concurrent child inserts referencing
12371                                // the old key.
12372                                self.acquire_fk_lock(
12373                                    txn_id,
12374                                    *table_id,
12375                                    &old_key,
12376                                    crate::locks::LockMode::Exclusive,
12377                                    control,
12378                                )?;
12379                                for (child_index, child) in load_rows(*child_id)?.iter().enumerate()
12380                                {
12381                                    commit_prepare_checkpoint(control, child_index)?;
12382                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
12383                                        != Some(old_key.as_slice())
12384                                    {
12385                                        continue;
12386                                    }
12387                                    let replacement = staging.iter().find_map(|(id, op)| {
12388                                        if *id != *child_id {
12389                                            return None;
12390                                        }
12391                                        match op {
12392                                            Staged::Delete(id) if *id == child.row_id => Some(None),
12393                                            Staged::Update {
12394                                                row_id,
12395                                                new_row: cells,
12396                                                ..
12397                                            } if *row_id == child.row_id => {
12398                                                let map: HashMap<u16, Value> =
12399                                                    cells.iter().cloned().collect();
12400                                                Some(encode_composite_key(&fk.columns, &map))
12401                                            }
12402                                            _ => None,
12403                                        }
12404                                    });
12405                                    if replacement.is_some_and(|key| {
12406                                        key.as_deref() != Some(old_key.as_slice())
12407                                    }) {
12408                                        continue;
12409                                    }
12410                                    return Err(MongrelError::Conflict(format!(
12411                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
12412                                        fk.name
12413                                    )));
12414                                }
12415                            }
12416                        }
12417                    }
12418                }
12419                Staged::Delete(rid) => {
12420                    // FK ON DELETE RESTRICT: a child row (whose FK action is
12421                    // RESTRICT) referencing this parent blocks the delete.
12422                    // CASCADE/SET NULL children were expanded in Phase A.
12423                    let parent_handle = self.table_by_id(*table_id)?;
12424                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
12425                        continue;
12426                    };
12427                    for (child_id, child_name, child_schema) in &live {
12428                        for fk in &child_schema.constraints.foreign_keys {
12429                            if fk.ref_table != *tname || fk.on_delete != FkAction::Restrict {
12430                                continue;
12431                            }
12432                            let Some(parent_key) =
12433                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
12434                            else {
12435                                continue;
12436                            };
12437                            let child_rows = load_rows(*child_id)?;
12438                            for (row_index, r) in child_rows.iter().enumerate() {
12439                                commit_prepare_checkpoint(control, row_index)?;
12440                                // A child already being deleted by this txn
12441                                // (cascade/inline) is not a restrict violation.
12442                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
12443                                    continue;
12444                                }
12445                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
12446                                    if ck == parent_key {
12447                                        return Err(MongrelError::Conflict(format!(
12448                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
12449                                            fk.name
12450                                        )));
12451                                    }
12452                                }
12453                            }
12454                        }
12455                    }
12456                }
12457                Staged::Truncate => {
12458                    // Truncate is RESTRICT-only: reject if any child references
12459                    // this table (any FK action), since cascade-truncate is
12460                    // unsupported.
12461                    for (child_id, child_name, child_schema) in &live {
12462                        for fk in &child_schema.constraints.foreign_keys {
12463                            if fk.ref_table != *tname {
12464                                continue;
12465                            }
12466                            let child_rows = load_rows(*child_id)?;
12467                            if child_rows
12468                                .iter()
12469                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
12470                            {
12471                                return Err(MongrelError::Conflict(format!(
12472                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
12473                                    fk.name
12474                                )));
12475                            }
12476                        }
12477                    }
12478                }
12479            }
12480        }
12481        Ok(())
12482    }
12483
12484    fn validate_write_permissions(
12485        &self,
12486        staging: &[(u64, crate::txn::Staged)],
12487        principal: Option<&crate::auth::Principal>,
12488        control: Option<&crate::ExecutionControl>,
12489    ) -> Result<()> {
12490        commit_prepare_checkpoint(control, 0)?;
12491        if principal.is_none() && !self.auth_state.require_auth() {
12492            return Ok(());
12493        }
12494        let principal = principal.ok_or(MongrelError::AuthRequired)?;
12495        let needs = summarize_write_permissions(staging);
12496        let catalog = self.catalog.read();
12497
12498        if needs.values().any(|need| need.truncate) {
12499            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
12500        }
12501        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
12502            commit_prepare_checkpoint(control, need_index)?;
12503            let entry = catalog
12504                .tables
12505                .iter()
12506                .find(|entry| {
12507                    entry.table_id == table_id
12508                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
12509                })
12510                .ok_or_else(|| {
12511                    MongrelError::NotFound(format!(
12512                        "live table {table_id} not found during write validation"
12513                    ))
12514                })?;
12515            if matches!(entry.state, TableState::Building { .. }) {
12516                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
12517                continue;
12518            }
12519            if need.insert {
12520                Self::require_columns_for_principal(
12521                    &entry.name,
12522                    &entry.schema,
12523                    crate::auth::ColumnOperation::Insert,
12524                    &need.insert_columns,
12525                    principal,
12526                )?;
12527            }
12528            if need.update {
12529                Self::require_columns_for_principal(
12530                    &entry.name,
12531                    &entry.schema,
12532                    crate::auth::ColumnOperation::Update,
12533                    &need.update_columns,
12534                    principal,
12535                )?;
12536            }
12537            if need.delete {
12538                self.require_for(
12539                    Some(principal),
12540                    &crate::auth::Permission::Delete {
12541                        table: entry.name.clone(),
12542                    },
12543                )?;
12544            }
12545        }
12546        Ok(())
12547    }
12548
12549    fn validate_security_writes(
12550        &self,
12551        staging: &[(u64, crate::txn::Staged)],
12552        read_epoch: Epoch,
12553        explicit_principal: Option<&crate::auth::Principal>,
12554        control: Option<&crate::ExecutionControl>,
12555    ) -> Result<()> {
12556        commit_prepare_checkpoint(control, 0)?;
12557        use crate::security::PolicyCommand;
12558        use crate::txn::Staged;
12559
12560        let catalog = self.catalog.read();
12561        if catalog.security.rls_tables.is_empty() {
12562            return Ok(());
12563        }
12564        let security = catalog.security.clone();
12565        let table_names = catalog
12566            .tables
12567            .iter()
12568            .filter(|entry| matches!(entry.state, TableState::Live))
12569            .map(|entry| (entry.table_id, entry.name.clone()))
12570            .collect::<HashMap<_, _>>();
12571        drop(catalog);
12572        if !staging.iter().any(|(table_id, _)| {
12573            table_names
12574                .get(table_id)
12575                .is_some_and(|table| security.rls_enabled(table))
12576        }) {
12577            return Ok(());
12578        }
12579        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
12580
12581        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
12582            commit_prepare_checkpoint(control, operation_index)?;
12583            let Some(table) = table_names.get(table_id) else {
12584                continue;
12585            };
12586            if !security.rls_enabled(table) || principal.is_admin {
12587                continue;
12588            }
12589            let denied = |command| MongrelError::PermissionDenied {
12590                required: match command {
12591                    PolicyCommand::Insert => crate::auth::Permission::Insert {
12592                        table: table.clone(),
12593                    },
12594                    PolicyCommand::Update => crate::auth::Permission::Update {
12595                        table: table.clone(),
12596                    },
12597                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
12598                        crate::auth::Permission::Delete {
12599                            table: table.clone(),
12600                        }
12601                    }
12602                },
12603                principal: principal.username.clone(),
12604            };
12605            match operation {
12606                Staged::Put(cells) => {
12607                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
12608                    row.columns.extend(cells.iter().cloned());
12609                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
12610                        return Err(denied(PolicyCommand::Insert));
12611                    }
12612                }
12613                Staged::Update {
12614                    row_id,
12615                    new_row: cells,
12616                    ..
12617                } => {
12618                    let old = self
12619                        .table_by_id(*table_id)?
12620                        .lock()
12621                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12622                        .ok_or_else(|| {
12623                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12624                        })?;
12625                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
12626                        return Err(denied(PolicyCommand::Update));
12627                    }
12628                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
12629                    new.columns.extend(cells.iter().cloned());
12630                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
12631                        return Err(denied(PolicyCommand::Update));
12632                    }
12633                }
12634                Staged::Delete(row_id) => {
12635                    let old = self
12636                        .table_by_id(*table_id)?
12637                        .lock()
12638                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12639                        .ok_or_else(|| {
12640                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12641                        })?;
12642                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
12643                        return Err(denied(PolicyCommand::Delete));
12644                    }
12645                }
12646                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
12647            }
12648        }
12649        Ok(())
12650    }
12651
12652    /// Seal a transaction (spec §9.3):
12653    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
12654    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
12655    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
12656    ///    group-sync, record conflict keys.
12657    /// 3. Publish — apply to tables, advance visible in-order.
12658    #[allow(clippy::too_many_arguments)]
12659    pub(crate) fn commit_transaction_with_external_states(
12660        &self,
12661        txn_id: u64,
12662        read_epoch: Epoch,
12663        staging: Vec<(u64, crate::txn::Staged)>,
12664        external_states: Vec<(String, Vec<u8>)>,
12665        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12666        security_principal: Option<crate::auth::Principal>,
12667        principal_catalog_bound: bool,
12668        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12669        context: crate::txn::TxnCommitContext,
12670    ) -> Result<(Epoch, Vec<RowId>)> {
12671        self.commit_transaction_with_external_states_inner(
12672            txn_id,
12673            read_epoch,
12674            staging,
12675            external_states,
12676            materialized_view_updates,
12677            security_principal,
12678            principal_catalog_bound,
12679            external_trigger_bridge,
12680            context,
12681            None,
12682            None,
12683        )
12684    }
12685
12686    #[allow(clippy::too_many_arguments)]
12687    pub(crate) fn commit_transaction_with_external_states_controlled(
12688        &self,
12689        txn_id: u64,
12690        read_epoch: Epoch,
12691        staging: Vec<(u64, crate::txn::Staged)>,
12692        external_states: Vec<(String, Vec<u8>)>,
12693        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12694        security_principal: Option<crate::auth::Principal>,
12695        principal_catalog_bound: bool,
12696        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12697        context: crate::txn::TxnCommitContext,
12698        control: &crate::ExecutionControl,
12699        before_commit: &mut dyn FnMut() -> Result<()>,
12700    ) -> Result<(Epoch, Vec<RowId>)> {
12701        self.commit_transaction_with_external_states_inner(
12702            txn_id,
12703            read_epoch,
12704            staging,
12705            external_states,
12706            materialized_view_updates,
12707            security_principal,
12708            principal_catalog_bound,
12709            external_trigger_bridge,
12710            context,
12711            Some(control),
12712            Some(before_commit),
12713        )
12714    }
12715
12716    #[allow(clippy::too_many_arguments)]
12717    fn commit_transaction_with_external_states_inner(
12718        &self,
12719        txn_id: u64,
12720        read_epoch: Epoch,
12721        mut staging: Vec<(u64, crate::txn::Staged)>,
12722        external_states: Vec<(String, Vec<u8>)>,
12723        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12724        mut security_principal: Option<crate::auth::Principal>,
12725        principal_catalog_bound: bool,
12726        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12727        context: crate::txn::TxnCommitContext,
12728        control: Option<&crate::ExecutionControl>,
12729        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
12730    ) -> Result<(Epoch, Vec<RowId>)> {
12731        use crate::memtable::Row;
12732        use crate::txn::{Staged, StagedOp, WriteKey};
12733        use crate::wal::Op;
12734        use std::collections::hash_map::DefaultHasher;
12735        use std::hash::{Hash, Hasher};
12736        use std::sync::atomic::Ordering;
12737
12738        if txn_id == crate::wal::SYSTEM_TXN_ID {
12739            return Err(MongrelError::Full(
12740                "per-open transaction id namespace exhausted; reopen the database".into(),
12741            ));
12742        }
12743        if self.read_only {
12744            return Err(MongrelError::ReadOnlyReplica);
12745        }
12746        commit_prepare_checkpoint(control, 0)?;
12747        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
12748        self.refresh_security_catalog_if_stale(observed_security_version)?;
12749        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
12750        if self.auth_state.require_auth() && security_principal.is_none() {
12751            return Err(MongrelError::AuthRequired);
12752        }
12753        {
12754            let catalog = self.catalog.read();
12755            if principal_catalog_bound
12756                || security_principal
12757                    .as_ref()
12758                    .is_some_and(|principal| principal.user_id != 0)
12759            {
12760                let principal = security_principal
12761                    .as_ref()
12762                    .ok_or(MongrelError::AuthRequired)?;
12763                security_principal =
12764                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
12765                if security_principal.is_none() {
12766                    return Err(MongrelError::AuthRequired);
12767                }
12768            }
12769        }
12770        let _replication_guard = self.replication_barrier.read();
12771        if self.poisoned.load(Ordering::Relaxed) {
12772            return Err(MongrelError::Other(
12773                "database poisoned by fsync error".into(),
12774            ));
12775        }
12776        // S1A-004: admit the commit as one core operation (rejects once the
12777        // core is draining, closing, closed, or lifecycle-poisoned; the legacy
12778        // fsync-poison error above still wins for a WAL-poisoned core).
12779        let _operation = self.admit_operation()?;
12780
12781        // ── S1B-003: user transactions hold the schema barrier Shared for the
12782        // whole commit so DDL (Exclusive) excludes concurrent DML. Internal
12783        // commits (catalog backfills, external-table state — `context.state`
12784        // is `None`) skip it: they run under their DDL operation's own
12785        // Exclusive hold, and acquiring a second, differently-keyed hold here
12786        // would self-deadlock. The guard releases every lock this commit
12787        // acquired — schema barrier, unique claims, sequence barriers, FK
12788        // holds — on every exit path (S1B-004 step 12).
12789        if context.state.is_some() {
12790            self.acquire_txn_lock(
12791                txn_id,
12792                crate::locks::LockKey::schema_barrier(),
12793                crate::locks::LockMode::Shared,
12794                control,
12795            )?;
12796        }
12797        let _txn_lock_guard = TxnLockGuard {
12798            locks: &self.lock_manager,
12799            txn_id,
12800        };
12801
12802        // ── S1B-005: idempotency check BEFORE the proposal. A repeated key
12803        // with an identical request replays the original receipt without
12804        // re-executing; a different request under the same key conflicts; a
12805        // new key is reserved durably before any WAL record can become
12806        // durable, so a crash mid-commit fails closed on retry.
12807        let idempotency_request = match &context.idempotency {
12808            Some(request) => match self.idempotency.check_and_reserve(request)? {
12809                crate::txn::IdempotencyCheck::Replay(receipt) => {
12810                    let epoch = Epoch(receipt.log_position.index);
12811                    if let Some(state) = &context.state {
12812                        state.committed(receipt);
12813                    }
12814                    return Ok((epoch, Vec::new()));
12815                }
12816                crate::txn::IdempotencyCheck::Reserved => Some(request.clone()),
12817            },
12818            None => None,
12819        };
12820        // Release the reservation on every pre-receipt failure path.
12821        let mut idempotency_guard = idempotency_request.as_ref().map(|request| {
12822            crate::txn::IdempotencyReservationGuard::new(&self.idempotency, request.clone())
12823        });
12824        let mut external_states = dedup_external_states(external_states);
12825        if !external_states.is_empty() {
12826            let cat = self.catalog.read();
12827            for (name, _) in &external_states {
12828                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
12829                    return Err(MongrelError::NotFound(format!(
12830                        "external table {name:?} not found"
12831                    )));
12832                }
12833            }
12834        }
12835        let prepared_materialized_views = {
12836            let mut deduplicated = HashMap::new();
12837            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
12838            {
12839                commit_prepare_checkpoint(control, definition_index)?;
12840                if definition.name.is_empty() || definition.query.trim().is_empty() {
12841                    return Err(MongrelError::InvalidArgument(
12842                        "materialized view name and query must not be empty".into(),
12843                    ));
12844                }
12845                deduplicated.insert(definition.name.clone(), definition);
12846            }
12847            let catalog = self.catalog.read();
12848            let mut prepared = Vec::with_capacity(deduplicated.len());
12849            for (definition_index, definition) in deduplicated.into_values().enumerate() {
12850                commit_prepare_checkpoint(control, definition_index)?;
12851                let table_id = catalog
12852                    .live(&definition.name)
12853                    .ok_or_else(|| {
12854                        MongrelError::NotFound(format!(
12855                            "materialized view table {:?} not found",
12856                            definition.name
12857                        ))
12858                    })?
12859                    .table_id;
12860                prepared.push((table_id, definition));
12861            }
12862            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
12863            prepared
12864        };
12865
12866        // ── 1. Prepare: fill generated values, expand triggers, validate, then
12867        // derive write keys from the final atomic write set.
12868        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12869        self.expand_table_triggers(
12870            txn_id,
12871            &mut staging,
12872            read_epoch,
12873            external_trigger_bridge,
12874            &mut external_states,
12875            control,
12876        )?;
12877        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12878        external_states = dedup_external_states(external_states);
12879        let expected_external_generations = {
12880            let catalog = self.catalog.read();
12881            let mut generations = HashMap::with_capacity(external_states.len());
12882            for (name, _) in &external_states {
12883                let entry = catalog
12884                    .external_tables
12885                    .iter()
12886                    .find(|entry| entry.name == *name)
12887                    .ok_or_else(|| {
12888                        MongrelError::NotFound(format!("external table {name:?} not found"))
12889                    })?;
12890                generations.insert(name.clone(), entry.created_epoch);
12891            }
12892            generations
12893        };
12894
12895        // S1B-003: claim every unique key this transaction inserts (primary
12896        // keys and declared UNIQUE constraints) in Exclusive mode BEFORE
12897        // validation reads. Concurrent inserts of the same key serialize on
12898        // the claim instead of racing to a write-write conflict; the
12899        // first-committer-wins index below remains the safety net.
12900        self.acquire_unique_key_claims(txn_id, &staging, control)?;
12901        // Fail unauthorized source writes before constraint expansion reads
12902        // existing rows. Constraint-generated writes are checked again inside
12903        // `validate_constraints` before any embedding provider is invoked.
12904        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
12905        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
12906        // Validate declarative constraints (unique / FK / check) under the read
12907        // snapshot, outside the WAL mutex. Trigger-produced writes are included
12908        // here, so the batch either satisfies every declared constraint or is
12909        // rejected atomically. This also materializes generated vectors after
12910        // all trigger and constraint-action writes exist, but before WAL append.
12911        self.validate_constraints(
12912            txn_id,
12913            &mut staging,
12914            read_epoch,
12915            security_principal.as_ref(),
12916            control,
12917        )?;
12918        let mut normalized = Vec::with_capacity(staging.len() * 2);
12919        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
12920            commit_prepare_checkpoint(control, staged_index)?;
12921            match op {
12922                crate::txn::Staged::Update {
12923                    row_id,
12924                    new_row: cells,
12925                    ..
12926                } => {
12927                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
12928                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
12929                }
12930                op => normalized.push((table_id, op)),
12931            }
12932        }
12933        staging = normalized;
12934        let has_changes = !staging.is_empty()
12935            || !external_states.is_empty()
12936            || !prepared_materialized_views.is_empty();
12937        let truncated_tables: HashSet<u64> = staging
12938            .iter()
12939            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
12940            .collect();
12941
12942        let write_keys = {
12943            let cat = self.catalog.read();
12944            let mut keys: Vec<WriteKey> = Vec::new();
12945            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12946                commit_prepare_checkpoint(control, staged_index)?;
12947                match staged {
12948                    Staged::Put(cells) => {
12949                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
12950                            for col in &entry.schema.columns {
12951                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
12952                                    if let Some((_, val)) =
12953                                        cells.iter().find(|(id, _)| *id == col.id)
12954                                    {
12955                                        let mut h = DefaultHasher::new();
12956                                        val.encode_key().hash(&mut h);
12957                                        keys.push(WriteKey::Unique {
12958                                            table_id: *table_id,
12959                                            index_id: 0,
12960                                            key_hash: h.finish(),
12961                                        });
12962                                    }
12963                                }
12964                            }
12965                            // Declared non-PK unique constraints register a
12966                            // `WriteKey::Unique` (namespace-separated from the
12967                            // PK's index_id==0 by setting the high bit) so two
12968                            // concurrent transactions inserting the same key
12969                            // cannot both commit. Rows with any NULL constrained
12970                            // column are skipped (SQL semantics).
12971                            if !entry.schema.constraints.uniques.is_empty() {
12972                                let cells_map: HashMap<u16, Value> =
12973                                    cells.iter().cloned().collect();
12974                                for uc in &entry.schema.constraints.uniques {
12975                                    if let Some(key_bytes) = crate::constraint::encode_composite_key(
12976                                        &uc.columns,
12977                                        &cells_map,
12978                                    ) {
12979                                        let mut h = DefaultHasher::new();
12980                                        key_bytes.hash(&mut h);
12981                                        keys.push(WriteKey::Unique {
12982                                            table_id: *table_id,
12983                                            index_id: uc.id | 0x8000,
12984                                            key_hash: h.finish(),
12985                                        });
12986                                    }
12987                                }
12988                            }
12989                        }
12990                    }
12991                    Staged::Delete(rid) => keys.push(WriteKey::Row {
12992                        table_id: *table_id,
12993                        row_id: rid.0,
12994                    }),
12995                    Staged::Truncate => keys.push(WriteKey::Table {
12996                        table_id: *table_id,
12997                    }),
12998                    Staged::Update { .. } => {
12999                        return Err(MongrelError::Other(
13000                            "transaction contains an unnormalized update during preparation".into(),
13001                        ));
13002                    }
13003                }
13004            }
13005            for (external_index, (name, _)) in external_states.iter().enumerate() {
13006                commit_prepare_checkpoint(control, external_index)?;
13007                let mut h = DefaultHasher::new();
13008                name.hash(&mut h);
13009                keys.push(WriteKey::Unique {
13010                    table_id: EXTERNAL_TABLE_ID,
13011                    index_id: 0,
13012                    key_hash: h.finish(),
13013                });
13014            }
13015            keys
13016        };
13017
13018        // Opportunistic pruning.
13019        let min_active = self.active_txns.min_read_epoch();
13020        if min_active < u64::MAX {
13021            self.conflicts.prune_below(Epoch(min_active));
13022        }
13023
13024        // S1B-002 (Serializable): SSI certification keys — every tracked point
13025        // read as a row key, every tracked predicate/range read as a table
13026        // key. A concurrent commit covering any of them after this
13027        // transaction's read epoch is an rw-antidependency dangerous
13028        // structure; certification aborts rather than allowing a
13029        // non-serializable interleaving.
13030        let ssi_keys = match context.isolation.canonical() {
13031            crate::txn::IsolationLevel::Serializable => {
13032                crate::txn::ssi_validation_keys(&context.read_set, &context.predicate_set)
13033            }
13034            _ => Vec::new(),
13035        };
13036
13037        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
13038        // §8.5, review fix #17). Snapshot the conflict-index version so the
13039        // sequencer only re-checks if new commits arrived in the interim.
13040        if self.conflicts.conflicts(&write_keys, read_epoch) {
13041            return Err(MongrelError::Conflict(
13042                "write-write conflict (pre-validate, first-committer-wins)".into(),
13043            ));
13044        }
13045        if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
13046            return Err(MongrelError::SerializationFailure {
13047                message: "a concurrent commit invalidated this transaction's reads (pre-validate)"
13048                    .into(),
13049            });
13050        }
13051        let pre_validate_version = self.conflicts.version();
13052
13053        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
13054        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
13055        // streamed as Put records; they are linked at publish time.
13056        let mut spilled: Vec<SpilledRun> = Vec::new();
13057        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
13058        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
13059        // as the spill runs are live (registered on first spill, dropped at the
13060        // end of this function on commit/abort/error).
13061        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
13062        {
13063            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
13064            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
13065            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
13066                commit_prepare_checkpoint(control, staged_index)?;
13067                if let Staged::Put(cells) = staged {
13068                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
13069                        bytes.saturating_add(value.estimated_bytes())
13070                    });
13071                    let table_bytes = table_bytes.entry(*table_id).or_default();
13072                    *table_bytes = table_bytes.saturating_add(bytes);
13073                    put_indexes.entry(*table_id).or_default().push(staged_index);
13074                }
13075            }
13076            let tables = self.tables.read();
13077            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
13078                commit_prepare_checkpoint(control, table_index)?;
13079                if bytes
13080                    <= self
13081                        .spill_threshold
13082                        .load(std::sync::atomic::Ordering::Relaxed)
13083                {
13084                    continue;
13085                }
13086                let Some(handle) = tables.get(&table_id) else {
13087                    continue;
13088                };
13089                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
13090                let mut t = handle.lock();
13091                let tdir = t.table_dir().to_path_buf();
13092                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
13093                std::fs::create_dir_all(&txn_dir)?;
13094                let run_id = t.alloc_run_id()? as u128;
13095                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
13096                let final_path = t.run_path(run_id as u64);
13097
13098                let mut rows: Vec<Row> = Vec::new();
13099                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
13100                    commit_prepare_checkpoint(control, put_index)?;
13101                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
13102                        return Err(MongrelError::Other(
13103                            "transaction put index no longer references a put".into(),
13104                        ));
13105                    };
13106                    t.validate_cells_not_null(cells)?;
13107                    let row_id = t.alloc_row_id()?;
13108                    let mut row = Row::new(row_id, Epoch(0));
13109                    row.columns.extend(std::mem::take(cells));
13110                    rows.push(row);
13111                }
13112                let schema = t.schema_ref().clone();
13113                let kek = t.kek_ref().cloned();
13114                let specs = t.indexable_column_specs();
13115                drop(t);
13116
13117                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
13118                    .uniform_epoch(true);
13119                if let Some(ref kek) = kek {
13120                    writer = writer.with_encryption(kek.as_ref(), specs);
13121                }
13122                commit_prepare_checkpoint(control, 0)?;
13123                let header = writer.write(&pending_path, &rows)?;
13124                commit_prepare_checkpoint(control, 0)?;
13125                let row_count = header.row_count;
13126                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
13127                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
13128
13129                spilled.push(SpilledRun {
13130                    table_id,
13131                    run_id,
13132                    pending_path,
13133                    final_path,
13134                    rows,
13135                    row_count,
13136                    min_rid,
13137                    max_rid,
13138                    content_hash: header.content_hash,
13139                });
13140                spilled_tables.insert(table_id);
13141            }
13142        }
13143
13144        // Test seam: let a test race `gc()` against this in-flight spill.
13145        if spill_guard.is_some() {
13146            if let Some(hook) = self.spill_hook.lock().as_ref() {
13147                hook();
13148            }
13149        }
13150
13151        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
13152        // Allocating row ids + building the rows here (lock order: table handle →
13153        // nothing) means the sequencer never locks a table handle while holding
13154        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
13155        // the table handle THEN the shared WAL; if the sequencer did the reverse
13156        // (WAL then handle) the two paths would deadlock (review fix: B1).
13157        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
13158        // Row ids are allocated here, before the sequencer's delta conflict
13159        // re-check, so a losing txn leaks the ids it reserved — harmless, the
13160        // u64 row-id space is monotonic and gaps are expected (spills do the same).
13161        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13162            .take(staging.len())
13163            .collect();
13164        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13165            .take(staging.len())
13166            .collect();
13167        {
13168            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
13169            for (index, (table_id, staged)) in staging.iter().enumerate() {
13170                commit_prepare_checkpoint(control, index)?;
13171                if matches!(staged, Staged::Delete(_))
13172                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
13173                {
13174                    indexes_by_table.entry(*table_id).or_default().push(index);
13175                }
13176            }
13177            let tables = self.tables.read();
13178            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
13179                commit_prepare_checkpoint(control, table_index)?;
13180                let handle = tables.get(&table_id).ok_or_else(|| {
13181                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13182                })?;
13183                #[cfg(test)]
13184                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13185                let mut t = handle.lock();
13186                for (prepare_index, index) in indexes.into_iter().enumerate() {
13187                    commit_prepare_checkpoint(control, prepare_index)?;
13188                    match &staging[index].1 {
13189                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
13190                            t.validate_cells_not_null(cells)?;
13191                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
13192                            for (column, value) in cells {
13193                                row.columns.insert(*column, value.clone());
13194                            }
13195                            prebuilt[index] = Some(row);
13196                        }
13197                        Staged::Delete(row_id) => {
13198                            delete_images[index] =
13199                                t.get(*row_id, self.snapshot_for_epoch(read_epoch));
13200                        }
13201                        Staged::Put(_) | Staged::Truncate => {}
13202                        Staged::Update { .. } => {
13203                            return Err(MongrelError::Other(
13204                                "transaction contains an unnormalized update during row preparation"
13205                                    .into(),
13206                            ));
13207                        }
13208                    }
13209                }
13210            }
13211        }
13212
13213        // Finish every fallible index read before the commit marker can become
13214        // durable. Post-durable row/run metadata application is then entirely
13215        // in-memory and cannot stop halfway through a multi-table publish.
13216        let prepared_table_handles = {
13217            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
13218            let put_table_ids: HashSet<u64> = staging
13219                .iter()
13220                .filter_map(|(table_id, staged)| {
13221                    matches!(staged, Staged::Put(_)).then_some(*table_id)
13222                })
13223                .collect();
13224            let tables = self.tables.read();
13225            let mut handles = HashMap::with_capacity(table_ids.len());
13226            for (table_index, table_id) in table_ids.into_iter().enumerate() {
13227                commit_prepare_checkpoint(control, table_index)?;
13228                let handle = tables.get(&table_id).ok_or_else(|| {
13229                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13230                })?;
13231                if put_table_ids.contains(&table_id) {
13232                    match control {
13233                        Some(control) => {
13234                            handle.lock().prepare_durable_publish_controlled(control)?
13235                        }
13236                        None => handle.lock().prepare_durable_publish()?,
13237                    }
13238                }
13239                handles.insert(table_id, handle.clone());
13240            }
13241            handles
13242        };
13243
13244        // Link large-transaction spill files before WAL durability. The guard
13245        // restores their pending names on every error before WAL append begins;
13246        // publication only attaches already-present files in memory.
13247        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
13248
13249        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
13250            .iter()
13251            .map(|run| {
13252                (
13253                    run.table_id,
13254                    run.rows.iter().map(|row| row.row_id).collect(),
13255                )
13256            })
13257            .collect();
13258        let committed_row_ids = staging
13259            .iter()
13260            .enumerate()
13261            .filter_map(|(index, (table_id, staged))| {
13262                if !matches!(staged, Staged::Put(_)) {
13263                    return None;
13264                }
13265                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
13266                    spilled_row_ids
13267                        .get_mut(table_id)
13268                        .and_then(VecDeque::pop_front)
13269                })
13270            })
13271            .collect();
13272
13273        let mut prepared_external = Vec::with_capacity(external_states.len());
13274        for (external_index, (name, state)) in external_states.iter().enumerate() {
13275            commit_prepare_checkpoint(control, external_index)?;
13276            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
13277            prepared_external.push((name.clone(), state.clone(), pending));
13278        }
13279
13280        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
13281        //
13282        // The schema barrier's Shared hold covered the prepare/validate phases
13283        // above (every catalog, constraint, and claim read ran under a stable
13284        // schema); the publish below is guarded by the catalog-generation
13285        // check, exactly as before S1B-003. Release the barrier here — before
13286        // the sequencer — so a DDL waiting on its Exclusive hold may proceed
13287        // while this commit publishes (the generation check resolves that
13288        // race). Unique/sequence/FK claims stay held until the commit ends.
13289        if context.state.is_some() {
13290            self.lock_manager
13291                .release(txn_id, &crate::locks::LockKey::schema_barrier());
13292        }
13293        let added_runs: Vec<crate::wal::AddedRun> = spilled
13294            .iter()
13295            .map(|s| crate::wal::AddedRun {
13296                table_id: s.table_id,
13297                run_id: s.run_id,
13298                row_count: s.row_count,
13299                level: 0,
13300                min_row_id: s.min_rid,
13301                max_row_id: s.max_rid,
13302                content_hash: s.content_hash,
13303            })
13304            .collect();
13305        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
13306            hook();
13307        }
13308        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
13309        // Security mutations cannot overtake an authorized commit before its
13310        // commit marker is durable.
13311        let security_guard = self.security_coordinator.gate.read();
13312        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
13313            return Err(MongrelError::Conflict(
13314                "security policy changed during write".into(),
13315            ));
13316        }
13317        if spill_guard.is_some() {
13318            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
13319                hook();
13320            }
13321        }
13322        let commit_guard = self.commit_lock.lock();
13323        let catalog_generation_result = (|| {
13324            {
13325                let catalog = self.catalog.read();
13326                for table_id in prepared_table_handles.keys() {
13327                    let is_current = catalog.tables.iter().any(|entry| {
13328                        entry.table_id == *table_id
13329                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
13330                    });
13331                    if !is_current {
13332                        return Err(MongrelError::Conflict(format!(
13333                            "table {table_id} changed during transaction preparation"
13334                        )));
13335                    }
13336                }
13337                for (name, created_epoch) in &expected_external_generations {
13338                    let current = catalog
13339                        .external_tables
13340                        .iter()
13341                        .find(|entry| entry.name == *name)
13342                        .map(|entry| entry.created_epoch);
13343                    if current != Some(*created_epoch) {
13344                        return Err(MongrelError::Conflict(format!(
13345                            "external table {name:?} changed during transaction preparation"
13346                        )));
13347                    }
13348                }
13349                for (table_id, definition) in &prepared_materialized_views {
13350                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
13351                    if current != Some(*table_id) {
13352                        return Err(MongrelError::Conflict(format!(
13353                            "materialized view {:?} changed during transaction preparation",
13354                            definition.name
13355                        )));
13356                    }
13357                }
13358                if trigger_catalog_binding(&catalog) != trigger_binding {
13359                    return Err(MongrelError::Conflict(
13360                        "trigger or referenced table generation changed during transaction preparation"
13361                            .into(),
13362                    ));
13363                }
13364            }
13365            let tables = self.tables.read();
13366            for (table_id, prepared) in &prepared_table_handles {
13367                if !tables
13368                    .get(table_id)
13369                    .is_some_and(|current| current.ptr_eq(prepared))
13370                {
13371                    return Err(MongrelError::Conflict(format!(
13372                        "table {table_id} mount changed during transaction preparation"
13373                    )));
13374                }
13375            }
13376            Ok(())
13377        })();
13378        if let Err(error) = catalog_generation_result {
13379            drop(commit_guard);
13380            for (_, _, pending) in &prepared_external {
13381                let _ = std::fs::remove_file(pending);
13382            }
13383            return Err(error);
13384        }
13385        // The commit lock keeps the next epoch stable while logical spill
13386        // records are serialized. Build them before taking the shared WAL
13387        // lock, and cap their aggregate memory/WAL footprint.
13388        let new_epoch = self.epoch.assigned().next();
13389        // S1B-004 step 5 / P0.5: allocate the commit HLC under the sequencer
13390        // lock *before* durable row payloads are encoded so every Put /
13391        // SpilledRows version carries the same commit_ts as the receipt.
13392        // Spec §8.2: strictly greater than every participant read/write
13393        // timestamp captured at `begin`.
13394        let commit_ts = match context.read_ts {
13395            Some(read_ts) => self.hlc.commit_timestamp([read_ts]),
13396            None => self.hlc.now().map_err(|skew| {
13397                MongrelError::Other(format!(
13398                    "clock skew rejected commit timestamp allocation: {skew}"
13399                ))
13400            })?,
13401        };
13402        let mut spilled_wal_bytes = 0;
13403        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
13404        let spill_prepare = (|| {
13405            for run in &mut spilled {
13406                for row in &mut run.rows {
13407                    row.committed_epoch = new_epoch;
13408                    row.commit_ts = Some(commit_ts);
13409                }
13410                for rows in encode_spilled_row_chunks(
13411                    &run.rows,
13412                    &mut spilled_wal_bytes,
13413                    SPILLED_WAL_TOTAL_MAX_BYTES,
13414                    control,
13415                )? {
13416                    spilled_wal_records.push((
13417                        run.table_id,
13418                        Op::SpilledRows {
13419                            table_id: run.table_id,
13420                            rows,
13421                        },
13422                    ));
13423                }
13424            }
13425            Result::<()>::Ok(())
13426        })();
13427        if let Err(error) = spill_prepare {
13428            for (_, _, pending) in &prepared_external {
13429                let _ = std::fs::remove_file(pending);
13430            }
13431            return Err(error);
13432        }
13433        let (
13434            new_epoch,
13435            mut _epoch_guard,
13436            applies,
13437            committed_materialized_views,
13438            commit_seq,
13439            commit_ts,
13440        ) = {
13441            let mut wal = self.shared_wal.lock();
13442
13443            // Re-check only if the conflict index advanced since pre-validation
13444            // (bounded delta — spec §8.5, review fix #17). If the version is
13445            // unchanged, the pre-check result is still valid and the sequencer
13446            // does O(1) work regardless of write-set size.
13447            if self.conflicts.version() != pre_validate_version {
13448                if self.conflicts.conflicts(&write_keys, read_epoch) {
13449                    // Abort: this txn assigned no epoch yet. The prepared-run guard
13450                    // restores final run names to their pending paths on return.
13451                    drop(wal);
13452                    for (_, _, pending) in &prepared_external {
13453                        let _ = std::fs::remove_file(pending);
13454                    }
13455                    return Err(MongrelError::Conflict(
13456                        "write-write conflict (sequencer delta re-check)".into(),
13457                    ));
13458                }
13459                if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
13460                    // S1B-002 dangerous structure: a commit that landed between
13461                    // the pre-check and the sequencer invalidated a tracked
13462                    // read. Abort before assigning any epoch.
13463                    drop(wal);
13464                    for (_, _, pending) in &prepared_external {
13465                        let _ = std::fs::remove_file(pending);
13466                    }
13467                    return Err(MongrelError::SerializationFailure {
13468                        message:
13469                            "a concurrent commit invalidated this transaction's reads (sequencer re-check)"
13470                                .into(),
13471                    });
13472                }
13473            }
13474
13475            if let Some(control) = control {
13476                if let Err(error) = control.checkpoint() {
13477                    drop(wal);
13478                    for (_, _, pending) in &prepared_external {
13479                        let _ = std::fs::remove_file(pending);
13480                    }
13481                    return Err(error);
13482                }
13483            }
13484            let mut applies = Vec::<TableApplyBatch>::new();
13485            let mut apply_indexes = HashMap::<u64, usize>::new();
13486            let mut committed_materialized_views = Vec::new();
13487            let mut wal_records = spilled_wal_records;
13488
13489            let mut index = 0;
13490            while index < staging.len() {
13491                let table_id = staging[index].0;
13492                let handle = prepared_table_handles
13493                    .get(&table_id)
13494                    .cloned()
13495                    .ok_or_else(|| {
13496                        MongrelError::NotFound(format!("table {table_id} not prepared"))
13497                    })?;
13498                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
13499                    let index = applies.len();
13500                    applies.push(TableApplyBatch {
13501                        table_id,
13502                        handle,
13503                        ops: Vec::new(),
13504                    });
13505                    index
13506                });
13507
13508                // Skip puts for tables that were spilled — their data is in a
13509                // pending run, not in streamed Put records.
13510                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
13511                {
13512                    index += 1;
13513                    continue;
13514                }
13515
13516                match &staging[index].1 {
13517                    Staged::Put(_) => {
13518                        let mut rows = Vec::new();
13519                        while index < staging.len()
13520                            && staging[index].0 == table_id
13521                            && matches!(&staging[index].1, Staged::Put(_))
13522                        {
13523                            let mut row = prebuilt[index].take().ok_or_else(|| {
13524                                MongrelError::Other(
13525                                    "transaction prepare lost a prebuilt put row".into(),
13526                                )
13527                            })?;
13528                            row.committed_epoch = new_epoch;
13529                            row.commit_ts = Some(commit_ts);
13530                            rows.push(row);
13531                            index += 1;
13532                        }
13533                        let payload = bincode::serialize(&rows)
13534                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
13535                        wal_records.push((
13536                            table_id,
13537                            Op::Put {
13538                                table_id,
13539                                rows: payload,
13540                            },
13541                        ));
13542                        applies[batch_index].ops.push(StagedOp::Put(rows));
13543                    }
13544                    Staged::Delete(_) => {
13545                        let mut row_ids = Vec::new();
13546                        while index < staging.len()
13547                            && staging[index].0 == table_id
13548                            && matches!(&staging[index].1, Staged::Delete(_))
13549                        {
13550                            let Staged::Delete(row_id) = &staging[index].1 else {
13551                                return Err(MongrelError::Other(
13552                                    "transaction delete batch changed during WAL preparation"
13553                                        .into(),
13554                                ));
13555                            };
13556                            if let Some(before) = &delete_images[index] {
13557                                wal_records.push((
13558                                    table_id,
13559                                    Op::BeforeImage {
13560                                        table_id,
13561                                        row_id: *row_id,
13562                                        row: bincode::serialize(before).map_err(|error| {
13563                                            MongrelError::Other(format!(
13564                                                "before-image serialize: {error}"
13565                                            ))
13566                                        })?,
13567                                    },
13568                                ));
13569                            }
13570                            row_ids.push(*row_id);
13571                            index += 1;
13572                        }
13573                        wal_records.push((
13574                            table_id,
13575                            Op::Delete {
13576                                table_id,
13577                                row_ids: row_ids.clone(),
13578                            },
13579                        ));
13580                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
13581                    }
13582                    Staged::Truncate => {
13583                        wal_records.push((table_id, Op::TruncateTable { table_id }));
13584                        applies[batch_index].ops.push(StagedOp::Truncate);
13585                        index += 1;
13586                    }
13587                    Staged::Update { .. } => {
13588                        return Err(MongrelError::Other(
13589                            "transaction contains an unnormalized update at the sequencer".into(),
13590                        ));
13591                    }
13592                }
13593            }
13594
13595            for (name, state, _) in &prepared_external {
13596                wal_records.push((
13597                    EXTERNAL_TABLE_ID,
13598                    Op::ExternalTableState {
13599                        name: name.clone(),
13600                        state: state.clone(),
13601                    },
13602                ));
13603            }
13604
13605            for (table_id, definition) in &prepared_materialized_views {
13606                let mut definition = definition.clone();
13607                definition.last_refresh_epoch = new_epoch.0;
13608                wal_records.push((
13609                    *table_id,
13610                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
13611                        name: definition.name.clone(),
13612                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
13613                    }),
13614                ));
13615                committed_materialized_views.push(definition);
13616            }
13617            if !committed_materialized_views.is_empty() {
13618                let mut next_catalog = self.catalog.read().clone();
13619                for definition in &committed_materialized_views {
13620                    if let Some(existing) = next_catalog
13621                        .materialized_views
13622                        .iter_mut()
13623                        .find(|existing| existing.name == definition.name)
13624                    {
13625                        *existing = definition.clone();
13626                    } else {
13627                        next_catalog.materialized_views.push(definition.clone());
13628                    }
13629                }
13630                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13631                wal_records.push((
13632                    WAL_TABLE_ID,
13633                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
13634                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
13635                    }),
13636                ));
13637            }
13638
13639            if let Some(control) = control {
13640                if let Err(error) = control.checkpoint() {
13641                    drop(wal);
13642                    for (_, _, pending) in &prepared_external {
13643                        let _ = std::fs::remove_file(pending);
13644                    }
13645                    return Err(error);
13646                }
13647            }
13648            if let Some(before_commit) = before_commit.as_mut() {
13649                if let Err(error) = before_commit() {
13650                    drop(wal);
13651                    for (_, _, pending) in &prepared_external {
13652                        let _ = std::fs::remove_file(pending);
13653                    }
13654                    return Err(error);
13655                }
13656            }
13657
13658            let assigned_epoch = self.epoch.bump_assigned();
13659            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
13660            if assigned_epoch != new_epoch {
13661                for (_, _, pending) in &prepared_external {
13662                    let _ = std::fs::remove_file(pending);
13663                }
13664                return Err(MongrelError::Conflict(
13665                    "commit epoch changed while sequencer lock was held".into(),
13666                ));
13667            }
13668
13669            // S1B-004 step 6: enter commit-critical before the proposal can
13670            // become durable. The HLC commit_ts was allocated above under the
13671            // commit lock so every row payload already carries it.
13672            // FND-006: `txn.decision.before` fires immediately before the
13673            // durable commit decision (enter CommitCritical / WAL proposal).
13674            // A Fail aborts while still Preparing — nothing durable yet.
13675            if let Err(fault) = mongreldb_fault::inject("txn.decision.before") {
13676                for (_, _, pending) in &prepared_external {
13677                    let _ = std::fs::remove_file(pending);
13678                }
13679                return Err(crate::commit_log::fault_as_io(fault));
13680            }
13681            if let Some(state) = &context.state {
13682                state.enter_commit_critical();
13683            }
13684
13685            // From this point the outcome can become ambiguous. Keep prepared
13686            // spill files at the final names referenced by a possibly durable
13687            // commit marker; orphan cleanup is safe when the append did fail.
13688            prepared_run_links.disarm();
13689
13690            // FND-004 (spec §9.4): the commit log owns the append step for the
13691            // transaction command (records + commit marker). The commit path
13692            // proposes through it; durability and the receipt follow below.
13693            let commit_seq = self
13694                .standalone_commit_log
13695                .append_transaction(
13696                    &mut wal,
13697                    txn_id,
13698                    new_epoch,
13699                    commit_ts,
13700                    wal_records,
13701                    &added_runs,
13702                )
13703                .map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
13704
13705            // Record the conflict + assign the epoch under the WAL lock so commit
13706            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
13707            // moves out of this critical section to the group-commit coordinator
13708            // so concurrent committers share a single leader fsync.
13709            self.conflicts.record(&write_keys, new_epoch);
13710            (
13711                new_epoch,
13712                _epoch_guard,
13713                applies,
13714                committed_materialized_views,
13715                commit_seq,
13716                commit_ts,
13717            )
13718        };
13719        drop(commit_guard);
13720
13721        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
13722        let receipt =
13723            self.await_durable_commit_with_ts(txn_id, commit_seq, new_epoch, commit_ts)?;
13724        drop(security_guard);
13725
13726        // ── S1B-005: record the receipt against the reserved idempotency key
13727        // (durable before the caller can observe success), so a repeated key
13728        // with an identical request replays this receipt.
13729        if let Some(request) = &idempotency_request {
13730            if let Err(error) =
13731                self.idempotency
13732                    .complete(request, txn_id, new_epoch, receipt.commit_ts)
13733            {
13734                return Err(MongrelError::DurableCommit {
13735                    epoch: new_epoch.0,
13736                    message: format!("idempotency record was not durable: {error}"),
13737                });
13738            }
13739            if let Some(guard) = idempotency_guard.as_mut() {
13740                guard.disarm();
13741            }
13742        }
13743
13744        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
13745        let publish_result: Result<()> = {
13746            let mut first_error = None;
13747            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
13748            for run in &spilled {
13749                spilled_by_table.entry(run.table_id).or_default().push(run);
13750            }
13751            let mut modified_tables = Vec::with_capacity(applies.len());
13752            // Apply every table completely before any fallible manifest write.
13753            // The visible epoch remains unchanged until all tables are coherent.
13754            for batch in applies {
13755                #[cfg(test)]
13756                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13757                let mut t = batch.handle.lock();
13758                for op in batch.ops {
13759                    match op {
13760                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
13761                        StagedOp::Delete(row_ids) => {
13762                            for row_id in row_ids {
13763                                t.apply_delete_at(row_id, new_epoch, Some(commit_ts));
13764                            }
13765                        }
13766                        StagedOp::Truncate => t.apply_truncate(new_epoch),
13767                    }
13768                }
13769                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
13770                    for run in runs {
13771                        t.link_run(crate::manifest::RunRef {
13772                            run_id: run.run_id,
13773                            level: 0,
13774                            epoch_created: new_epoch.0,
13775                            row_count: run.row_count,
13776                        });
13777                        t.apply_run_metadata_prepared(&run.rows)?;
13778                        if truncated_tables.contains(&batch.table_id) {
13779                            // TRUNCATE + spilled puts fully describe this table at
13780                            // the commit epoch. Endorse the epoch so clean-reopen
13781                            // recovery does not replay the truncate over the
13782                            // already-linked replacement run.
13783                            t.set_flushed_epoch(new_epoch);
13784                        }
13785                    }
13786                }
13787                t.invalidate_pending_cache();
13788                drop(t);
13789                modified_tables.push(batch.handle);
13790            }
13791
13792            // Checkpoint only after every live table carries the durable state.
13793            // Continue after one checkpoint failure so runtime publication stays
13794            // all-or-nothing; WAL recovery repairs failed files on reopen.
13795            for handle in modified_tables {
13796                #[cfg(test)]
13797                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
13798                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
13799                    first_error.get_or_insert(error);
13800                }
13801            }
13802            for (name, _, pending) in &prepared_external {
13803                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
13804                    first_error.get_or_insert(error);
13805                }
13806            }
13807            if !committed_materialized_views.is_empty() {
13808                let mut next_catalog = self.catalog.read().clone();
13809                for definition in committed_materialized_views {
13810                    if let Some(existing) = next_catalog
13811                        .materialized_views
13812                        .iter_mut()
13813                        .find(|existing| existing.name == definition.name)
13814                    {
13815                        *existing = definition;
13816                    } else {
13817                        next_catalog.materialized_views.push(definition);
13818                    }
13819                }
13820                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13821                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
13822                    first_error.get_or_insert(error);
13823                }
13824            }
13825            match first_error {
13826                Some(error) => Err(error),
13827                None => Ok(()),
13828            }
13829        };
13830
13831        if has_changes {
13832            let _ = self.change_wake.send(());
13833        }
13834        self.finish_durable_publish(new_epoch, &mut _epoch_guard, &receipt, publish_result)?;
13835        // S1B-001: the commit is durable and published — record the receipt on
13836        // the formal state. (Post-publish errors above return `DurableCommit`
13837        // and deliberately leave the state `CommitCritical`.)
13838        if let Some(state) = &context.state {
13839            state.committed(receipt.clone());
13840        }
13841        // FND-006: `txn.decision.after` fires only after the decision is
13842        // durable and the Committed receipt is published. A Fail must not
13843        // undo the decision — surface as DurableCommit (post-fence).
13844        mongreldb_fault::inject("txn.decision.after").map_err(|fault| {
13845            MongrelError::DurableCommit {
13846                epoch: new_epoch.0,
13847                message: fault.to_string(),
13848            }
13849        })?;
13850        Ok((new_epoch, committed_row_ids))
13851    }
13852
13853    /// Build an HLC-authoritative snapshot at the current visible watermark
13854    /// (P0.5). Prefers the durable receipt HLC for the visible epoch; falls
13855    /// back to the live clock so HLC-stamped rows remain comparable.
13856    pub fn visible_snapshot(&self) -> Snapshot {
13857        self.snapshot_for_epoch(self.epoch.visible())
13858    }
13859
13860    /// Build an HLC-pinned snapshot at a specific commit epoch (P0.5).
13861    ///
13862    /// Prefer the durable ledger stamp for `epoch`, then the live HLC clock,
13863    /// then [`HlcTimestamp::MAX`] so HLC-stamped versions remain readable.
13864    pub fn snapshot_for_epoch(&self, epoch: Epoch) -> Snapshot {
13865        let commit_ts = self
13866            .commit_ts_for_epoch(epoch)
13867            .filter(|ts| *ts != mongreldb_types::hlc::HlcTimestamp::ZERO)
13868            .or_else(|| self.hlc.now().ok())
13869            .unwrap_or(mongreldb_types::hlc::HlcTimestamp::MAX);
13870        Snapshot::at_hlc(epoch, commit_ts)
13871    }
13872
13873    /// Register a read snapshot at the current visible epoch + HLC and return
13874    /// it with a guard that retains it for GC until dropped.
13875    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
13876        let snap = self.visible_snapshot();
13877        let g = self.snapshots.register(snap.epoch);
13878        (snap, g)
13879    }
13880
13881    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
13882    /// retention.
13883    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
13884        let snap = self.visible_snapshot();
13885        let g = self.snapshots.register_owned(snap.epoch);
13886        (snap, g)
13887    }
13888
13889    /// Configure a rolling history window measured in prior commit epochs.
13890    /// The first enable starts at the current epoch because earlier versions
13891    /// may already have been compacted. Increasing the window likewise cannot
13892    /// recreate history that fell outside the previous guarantee.
13893    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
13894        let _guard = self.ddl_lock.lock();
13895        let current = self.epoch.visible();
13896        let (old_epochs, old_start) = self.snapshots.history_config();
13897        let earliest_already_guaranteed = if old_epochs == 0 {
13898            current
13899        } else {
13900            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
13901        };
13902        let start = if epochs == 0 {
13903            current
13904        } else {
13905            earliest_already_guaranteed
13906        };
13907        let published = std::cell::Cell::new(false);
13908        let result = write_history_retention(&self.root, epochs, start, || {
13909            self.snapshots.configure_history(epochs, start);
13910            published.set(true);
13911        });
13912        match result {
13913            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
13914                epoch: current.0,
13915                message: format!("history-retention publication was not durable: {error}"),
13916            }),
13917            result => result,
13918        }
13919    }
13920
13921    pub fn history_retention_epochs(&self) -> u64 {
13922        self.snapshots.history_config().0
13923    }
13924
13925    pub fn earliest_retained_epoch(&self) -> Epoch {
13926        let current = self.epoch.visible();
13927        self.snapshots.history_floor(current).unwrap_or(current)
13928    }
13929
13930    /// Pin a guaranteed historical epoch for the lifetime of the returned
13931    /// guard. Rejects future epochs and epochs outside the configured window.
13932    /// When a durable HLC receipt exists for `epoch`, the snapshot is
13933    /// HLC-authoritative (`at_hlc`); otherwise it falls back to epoch-only.
13934    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
13935        let current = self.epoch.visible();
13936        if epoch > current {
13937            return Err(MongrelError::InvalidArgument(format!(
13938                "epoch {} is in the future; current epoch is {}",
13939                epoch.0, current.0
13940            )));
13941        }
13942        let earliest = self.earliest_retained_epoch();
13943        if epoch < earliest {
13944            return Err(MongrelError::InvalidArgument(format!(
13945                "epoch {} is no longer retained; earliest available epoch is {}",
13946                epoch.0, earliest.0
13947            )));
13948        }
13949        let guard = self.snapshots.register_owned(epoch);
13950        let snap = match self.commit_ts_for_epoch(epoch) {
13951            Some(commit_ts) => Snapshot::at_hlc(epoch, commit_ts),
13952            None => Snapshot::at(epoch),
13953        };
13954        Ok((snap, guard))
13955    }
13956
13957    /// Names of all live tables.
13958    pub fn table_names(&self) -> Vec<String> {
13959        self.catalog
13960            .read()
13961            .tables
13962            .iter()
13963            .filter(|t| matches!(t.state, TableState::Live))
13964            .map(|t| t.name.clone())
13965            .collect()
13966    }
13967
13968    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
13969    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
13970    /// reaped on the next open. Call this as the last action before a
13971    /// short-lived process (CLI, one-shot script) exits. The daemon does not
13972    /// need this — its background auto-compactor handles run management.
13973    pub fn close(&self) -> Result<()> {
13974        for name in self.table_names() {
13975            if let Ok(handle) = self.table(&name) {
13976                if let Err(e) = handle.lock().close() {
13977                    eprintln!("[close] flush failed for {name}: {e}");
13978                }
13979            }
13980        }
13981        Ok(())
13982    }
13983
13984    /// Compact every mounted table: merge all sorted runs into one clean run
13985    /// so query cost stays flat (single-run fast path) instead of growing
13986    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
13987    /// rows to reclaim. Each table
13988    /// is locked individually for its own compaction; snapshot retention is
13989    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
13990    pub fn compact(&self) -> Result<(usize, usize)> {
13991        self.require(&crate::auth::Permission::Ddl)?;
13992        // S1A-004: admit the compaction pass as one core operation.
13993        let _operation = self.admit_operation()?;
13994        let mut compacted = 0;
13995        let mut skipped = 0;
13996        for name in self.table_names() {
13997            let Ok(handle) = self.table(&name) else {
13998                continue;
13999            };
14000            {
14001                let mut t = handle.lock();
14002                let before = t.run_count();
14003                if before < 2 && !t.should_compact() {
14004                    skipped += 1;
14005                    continue;
14006                }
14007                match t.compact() {
14008                    Ok(()) => {
14009                        let after = t.run_count();
14010                        compacted += 1;
14011                        eprintln!("[compact] {name}: {before} -> {after} runs");
14012                    }
14013                    Err(e) => {
14014                        eprintln!("[compact] {name}: compaction failed: {e}");
14015                        skipped += 1;
14016                    }
14017                }
14018            }
14019        }
14020        Ok((compacted, skipped))
14021    }
14022
14023    /// Compact a single table by name. Returns `Ok(true)` if it was
14024    /// compacted, `Ok(false)` if skipped (< 2 runs).
14025    pub fn compact_table(&self, name: &str) -> Result<bool> {
14026        self.require(&crate::auth::Permission::Ddl)?;
14027        let handle = self.table(name)?;
14028        let mut t = handle.lock();
14029        let before = t.run_count();
14030        if before < 2 {
14031            return Ok(false);
14032        }
14033        t.compact()?;
14034        Ok(t.run_count() < before)
14035    }
14036
14037    /// Look up a live table by name.
14038    pub fn table(&self, name: &str) -> Result<TableHandle> {
14039        self.ensure_owner_process()?;
14040        let cat = self.catalog.read();
14041        let entry = cat
14042            .live(name)
14043            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14044        let id = entry.table_id;
14045        drop(cat);
14046        self.tables
14047            .read()
14048            .get(&id)
14049            .cloned()
14050            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
14051    }
14052
14053    /// Rebuild HOT + secondary indexes for one live table from durable runs,
14054    /// the mutable run, and the memtable. Use after an index desync (rows
14055    /// visible on fullscan but missing from PK / equality lookups).
14056    pub fn rebuild_indexes(&self, table: &str) -> Result<()> {
14057        let handle = self.table(table)?;
14058        let mut guard = handle.lock();
14059        guard.rebuild_indexes()
14060    }
14061
14062    /// Rebuild indexes for every live mounted table. Returns the number of
14063    /// tables rebuilt.
14064    pub fn rebuild_all_indexes(&self) -> Result<usize> {
14065        let handles: Vec<_> = self.tables.read().values().cloned().collect();
14066        let mut n = 0usize;
14067        for handle in handles {
14068            handle.lock().rebuild_indexes()?;
14069            n += 1;
14070        }
14071        Ok(n)
14072    }
14073
14074    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
14075    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
14076    pub fn has_ttl_tables(&self) -> bool {
14077        self.tables
14078            .read()
14079            .values()
14080            .any(|table| table.lock().ttl().is_some())
14081    }
14082
14083    /// Resolve a live table id → mounted handle (used by the constraint
14084    /// validation pass and other id-qualified internal paths).
14085    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
14086        self.tables
14087            .read()
14088            .get(&id)
14089            .cloned()
14090            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
14091    }
14092
14093    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
14094    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
14095    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
14096    /// allocation; explicit values merely advance the counter (an
14097    /// order-insensitive `max` under the table lock) and must not serialize.
14098    pub(crate) fn table_auto_inc_would_allocate(
14099        &self,
14100        table_id: u64,
14101        cells: &[(u16, Value)],
14102    ) -> bool {
14103        let catalog = self.catalog.read();
14104        let Some(entry) = catalog
14105            .tables
14106            .iter()
14107            .find(|entry| entry.table_id == table_id)
14108        else {
14109            return false;
14110        };
14111        let Some(column) = entry.schema.auto_increment_column() else {
14112            return false;
14113        };
14114        matches!(
14115            cells.iter().find(|(id, _)| *id == column.id),
14116            None | Some((_, Value::Null))
14117        )
14118    }
14119
14120    /// Create a new table. The DDL is first logged to the shared WAL
14121    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
14122    /// BEFORE the in-memory catalog and table map are mutated; the catalog
14123    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
14124    /// that sees a stale catalog still recovers the table by replaying the Ddl.
14125    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
14126        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14127            return Err(MongrelError::InvalidArgument(format!(
14128                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
14129            )));
14130        }
14131        self.create_table_with_state(name, schema, TableState::Live)
14132    }
14133
14134    /// Create a durable but non-queryable CTAS build table.
14135    #[doc(hidden)]
14136    pub fn create_building_table(
14137        &self,
14138        build_name: &str,
14139        intended_name: &str,
14140        query_id: &str,
14141        schema: Schema,
14142    ) -> Result<u64> {
14143        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14144            || intended_name.is_empty()
14145            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14146            || query_id.is_empty()
14147        {
14148            return Err(MongrelError::InvalidArgument(
14149                "invalid CTAS building-table identity".into(),
14150            ));
14151        }
14152        self.create_table_with_state(
14153            build_name,
14154            schema,
14155            TableState::Building {
14156                intended_name: intended_name.to_string(),
14157                query_id: query_id.to_string(),
14158                created_at_unix_nanos: current_unix_nanos(),
14159                replaces_table_id: None,
14160            },
14161        )
14162    }
14163
14164    /// Create a hidden schema-rebuild table while the intended target remains
14165    /// live. Publication later validates that the same target is still live.
14166    #[doc(hidden)]
14167    pub fn create_rebuilding_table(
14168        &self,
14169        build_name: &str,
14170        intended_name: &str,
14171        query_id: &str,
14172        schema: Schema,
14173    ) -> Result<u64> {
14174        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14175            || intended_name.is_empty()
14176            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14177            || query_id.is_empty()
14178        {
14179            return Err(MongrelError::InvalidArgument(
14180                "invalid rebuilding-table identity".into(),
14181            ));
14182        }
14183        let replaces_table_id = self
14184            .catalog
14185            .read()
14186            .live(intended_name)
14187            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
14188            .table_id;
14189        self.create_table_with_state(
14190            build_name,
14191            schema,
14192            TableState::Building {
14193                intended_name: intended_name.to_string(),
14194                query_id: query_id.to_string(),
14195                created_at_unix_nanos: current_unix_nanos(),
14196                replaces_table_id: Some(replaces_table_id),
14197            },
14198        )
14199    }
14200
14201    fn create_table_with_state(
14202        &self,
14203        name: &str,
14204        schema: Schema,
14205        state: TableState,
14206    ) -> Result<u64> {
14207        use crate::wal::DdlOp;
14208        use std::sync::atomic::Ordering;
14209
14210        self.require(&crate::auth::Permission::Ddl)?;
14211        if self.poisoned.load(Ordering::Relaxed) {
14212            return Err(MongrelError::Other(
14213                "database poisoned by fsync error".into(),
14214            ));
14215        }
14216        // S1A-004: admit the DDL as one core operation.
14217        let _operation = self.admit_operation()?;
14218        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14219        let _g = self.ddl_lock.lock();
14220        let _security_write = self.security_write()?;
14221        self.require(&crate::auth::Permission::Ddl)?;
14222        {
14223            let cat = self.catalog.read();
14224            match &state {
14225                TableState::Live => {
14226                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
14227                        return Err(MongrelError::InvalidArgument(format!(
14228                            "table {name:?} already exists or is being built"
14229                        )));
14230                    }
14231                }
14232                TableState::Building {
14233                    intended_name,
14234                    replaces_table_id,
14235                    ..
14236                } => {
14237                    let target_matches = match replaces_table_id {
14238                        Some(table_id) => cat
14239                            .live(intended_name)
14240                            .is_some_and(|entry| entry.table_id == *table_id),
14241                        None => cat.live(intended_name).is_none(),
14242                    };
14243                    if !target_matches || cat.building_for(intended_name).is_some() {
14244                        return Err(MongrelError::InvalidArgument(format!(
14245                            "table {intended_name:?} changed or is already being built"
14246                        )));
14247                    }
14248                    if cat.building(name).is_some() {
14249                        return Err(MongrelError::InvalidArgument(format!(
14250                            "building table {name:?} already exists"
14251                        )));
14252                    }
14253                }
14254                TableState::Dropped { .. } => {
14255                    return Err(MongrelError::InvalidArgument(
14256                        "cannot create a dropped table".into(),
14257                    ));
14258                }
14259            }
14260        }
14261
14262        // Allocate id + epoch + txn id under the commit lock so the DDL commit
14263        // is serialized with data commits (in-order publish). Live creates
14264        // draw their table id from the routed catalog command below (S1F-001);
14265        // CTAS building-table creates keep the legacy direct allocation.
14266        let commit_lock = Arc::clone(&self.commit_lock);
14267        let _c = commit_lock.lock();
14268        let live_create = matches!(state, TableState::Live);
14269        let legacy_table_id = if live_create {
14270            None
14271        } else {
14272            let mut cat = self.catalog.write();
14273            let id = cat.next_table_id;
14274            cat.next_table_id = id
14275                .checked_add(1)
14276                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
14277            Some(id)
14278        };
14279        let epoch = self.epoch.bump_assigned();
14280        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14281        let txn_id = self.alloc_txn_id()?;
14282
14283        let mut schema = schema;
14284        if let Some(table_id) = legacy_table_id {
14285            // Stamp the schema_id with the unique table_id so every table in
14286            // the database has a distinct schema_id (caller-provided values
14287            // are ignored to prevent collisions).
14288            schema.schema_id = table_id;
14289            // Defense in depth: reject an invalid schema BEFORE any durable
14290            // side-effect. `Table::create_in` re-validates, but by then the
14291            // DDL has already been appended to the shared WAL; a failing
14292            // create_in would leave a dangling entry that `recover_ddl_from_wal`
14293            // replays without re-validating, corrupting the catalog on reopen.
14294            // Validating here keeps the WAL free of schemas that can never be
14295            // opened.
14296            schema.validate_auto_increment()?;
14297            schema.validate_defaults()?;
14298            schema.validate_ai()?;
14299            for index in &schema.indexes {
14300                index.validate_options()?;
14301            }
14302            for constraint in &schema.constraints.checks {
14303                constraint.expr.validate()?;
14304            }
14305        }
14306
14307        let mut next_catalog = self.catalog.read().clone();
14308        let (table_id, schema) = match legacy_table_id {
14309            Some(table_id) => {
14310                next_catalog.tables.push(CatalogEntry {
14311                    table_id,
14312                    name: name.to_string(),
14313                    schema: schema.clone(),
14314                    state: state.clone(),
14315                    created_epoch: epoch.0,
14316                });
14317                (table_id, schema)
14318            }
14319            None => {
14320                // S1F-001: the mutation is a versioned catalog command —
14321                // schema validation (the same five validators), table-id
14322                // allocation, and schema_id stamping all resolve inside it.
14323                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
14324                    name: name.to_string(),
14325                    schema,
14326                    created_epoch: epoch.0,
14327                };
14328                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
14329                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
14330                    return Err(MongrelError::Other(
14331                        "CreateTable resolved to an unexpected catalog delta".into(),
14332                    ));
14333                };
14334                (entry.table_id, entry.schema)
14335            }
14336        };
14337        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14338
14339        // Build the complete mounted table before its DDL can become durable.
14340        // Any failure removes the unpublished directory and abandons the epoch.
14341        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
14342        let canonical_tdir = self.root.join(&table_relative);
14343        let table_root = Arc::new(
14344            self.durable_root
14345                .create_directory_all_pinned(&table_relative)?,
14346        );
14347        let tdir = table_root.io_path()?;
14348        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
14349        let ctx = SharedCtx {
14350            root_guard: Some(table_root),
14351            epoch: Arc::clone(&self.epoch),
14352            page_cache: Arc::clone(&self.page_cache),
14353            decoded_cache: Arc::clone(&self.decoded_cache),
14354            snapshots: Arc::clone(&self.snapshots),
14355            kek: self.kek.clone(),
14356            commit_lock: Arc::clone(&self.commit_lock),
14357            shared: Some(crate::engine::SharedWalCtx {
14358                wal: Arc::clone(&self.shared_wal),
14359                group: Arc::clone(&self.group),
14360                poisoned: Arc::clone(&self.poisoned),
14361                txn_ids: Arc::clone(&self.next_txn_id),
14362                change_wake: self.change_wake.clone(),
14363                lifecycle: Arc::clone(&self.lifecycle),
14364                hlc: Arc::clone(&self.hlc),
14365            }),
14366            table_name: Some(name.to_string()),
14367            auth: self.table_auth_checker(),
14368            read_only: self.read_only,
14369        };
14370        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
14371
14372        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
14373        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
14374        let schema_json = DdlOp::encode_schema(&schema)?;
14375        let ddl = match &state {
14376            TableState::Live => DdlOp::CreateTable {
14377                table_id,
14378                name: name.to_string(),
14379                schema_json,
14380            },
14381            TableState::Building {
14382                intended_name,
14383                query_id,
14384                created_at_unix_nanos,
14385                replaces_table_id,
14386            } => match replaces_table_id {
14387                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
14388                    table_id,
14389                    build_name: name.to_string(),
14390                    intended_name: intended_name.clone(),
14391                    query_id: query_id.clone(),
14392                    created_at_unix_nanos: *created_at_unix_nanos,
14393                    replaces_table_id: *replaces_table_id,
14394                    schema_json,
14395                },
14396                None => DdlOp::CreateBuildingTable {
14397                    table_id,
14398                    build_name: name.to_string(),
14399                    intended_name: intended_name.clone(),
14400                    query_id: query_id.clone(),
14401                    created_at_unix_nanos: *created_at_unix_nanos,
14402                    schema_json,
14403                },
14404            },
14405            TableState::Dropped { .. } => {
14406                return Err(MongrelError::InvalidArgument(
14407                    "cannot create a table in dropped state".into(),
14408                ));
14409            }
14410        };
14411        let commit_seq = {
14412            let mut wal = self.shared_wal.lock();
14413            let append: Result<u64> = (|| {
14414                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14415                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14416                wal.append_commit(txn_id, epoch, &[])
14417            })();
14418            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14419        };
14420        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14421        pending_table_dir.disarm();
14422
14423        // Publish the mounted table and catalog in memory even if the catalog
14424        // checkpoint fails after the WAL commit.
14425        self.tables
14426            .write()
14427            .insert(table_id, TableHandle::new(table));
14428        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14429        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14430        Ok(table_id)
14431    }
14432
14433    /// Logically drop a table, logging the DDL through the shared WAL first.
14434    pub fn drop_table(&self, name: &str) -> Result<()> {
14435        self.drop_table_with_epoch(name).map(|_| ())
14436    }
14437
14438    /// Logically drop a table and return the exact publication epoch.
14439    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
14440        self.drop_table_with_state(name, false, None)
14441    }
14442
14443    pub fn drop_table_with_epoch_controlled<F>(
14444        &self,
14445        name: &str,
14446        mut before_commit: F,
14447    ) -> Result<Epoch>
14448    where
14449        F: FnMut() -> Result<()>,
14450    {
14451        self.drop_table_with_state(name, false, Some(&mut before_commit))
14452    }
14453
14454    /// Discard an unpublished CTAS build.
14455    #[doc(hidden)]
14456    pub fn discard_building_table(&self, name: &str) -> Result<()> {
14457        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14458            return Err(MongrelError::InvalidArgument(
14459                "not a CTAS building table".into(),
14460            ));
14461        }
14462        self.drop_table_with_state(name, true, None).map(|_| ())
14463    }
14464
14465    fn drop_table_with_state(
14466        &self,
14467        name: &str,
14468        building: bool,
14469        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14470    ) -> Result<Epoch> {
14471        use crate::wal::DdlOp;
14472        use std::sync::atomic::Ordering;
14473
14474        self.require(&crate::auth::Permission::Ddl)?;
14475        if self.poisoned.load(Ordering::Relaxed) {
14476            return Err(MongrelError::Other(
14477                "database poisoned by fsync error".into(),
14478            ));
14479        }
14480        // S1A-004: admit the DDL as one core operation.
14481        let _operation = self.admit_operation()?;
14482        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14483        let _g = self.ddl_lock.lock();
14484        let _security_write = self.security_write()?;
14485        self.require(&crate::auth::Permission::Ddl)?;
14486        let table_id = {
14487            let cat = self.catalog.read();
14488            if building {
14489                cat.building(name)
14490            } else {
14491                cat.live(name)
14492            }
14493            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
14494            .table_id
14495        };
14496
14497        let commit_lock = Arc::clone(&self.commit_lock);
14498        let _c = commit_lock.lock();
14499        let epoch = self.epoch.bump_assigned();
14500        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14501        let txn_id = self.alloc_txn_id()?;
14502        let mut next_catalog = self.catalog.read().clone();
14503        if building {
14504            // CTAS building-table discards stay on the legacy mutation: the
14505            // command model's `DropTable` resolves live tables only.
14506            let entry = next_catalog
14507                .tables
14508                .iter_mut()
14509                .find(|t| t.table_id == table_id)
14510                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14511            entry.state = TableState::Dropped { at_epoch: epoch.0 };
14512            next_catalog.triggers.retain(|trigger| {
14513                !matches!(
14514                    &trigger.trigger.target,
14515                    TriggerTarget::Table(target) if target == name
14516                )
14517            });
14518            next_catalog
14519                .materialized_views
14520                .retain(|definition| definition.name != name);
14521            next_catalog
14522                .security
14523                .rls_tables
14524                .retain(|table| table != name);
14525            next_catalog
14526                .security
14527                .policies
14528                .retain(|policy| policy.table != name);
14529            next_catalog
14530                .security
14531                .masks
14532                .retain(|mask| mask.table != name);
14533            for role in &mut next_catalog.roles {
14534                role.permissions
14535                    .retain(|permission| permission_table(permission) != Some(name));
14536            }
14537            advance_security_version(&mut next_catalog)?;
14538        } else {
14539            // S1F-001: a plain live-table drop is a versioned catalog command
14540            // (the delta mirrors the legacy mutation above, cascading
14541            // triggers, materialized views, and table-scoped security state).
14542            let command = crate::catalog_cmds::CatalogCommand::DropTable {
14543                name: name.to_string(),
14544                at_epoch: epoch.0,
14545            };
14546            self.apply_catalog_command_to(&mut next_catalog, command)?;
14547        }
14548        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14549        let commit_seq = {
14550            let mut wal = self.shared_wal.lock();
14551            if let Some(before_commit) = before_commit {
14552                before_commit()?;
14553            }
14554            let append: Result<u64> = (|| {
14555                wal.append(
14556                    txn_id,
14557                    table_id,
14558                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
14559                )?;
14560                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14561                wal.append_commit(txn_id, epoch, &[])
14562            })();
14563            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14564        };
14565        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14566
14567        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14568        self.tables.write().remove(&table_id);
14569        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14570        Ok(epoch)
14571    }
14572
14573    /// Rename a live table. `name` must exist and `new_name` must not collide
14574    /// with any live table; both checks run under `ddl_lock` so they are atomic
14575    /// with the rename and with concurrent `create_table` existence checks (no
14576    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
14577    /// side-effects. The rename is logged to the shared WAL as
14578    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
14579    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
14580    /// the in-memory object does not move — only the catalog name changes).
14581    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
14582        self.rename_table_with_epoch(name, new_name).map(|_| ())
14583    }
14584
14585    /// Rename a table and return its exact publication epoch.
14586    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
14587        self.rename_table_with_epoch_inner(name, new_name, None)
14588    }
14589
14590    pub fn rename_table_with_epoch_controlled<F>(
14591        &self,
14592        name: &str,
14593        new_name: &str,
14594        mut before_commit: F,
14595    ) -> Result<Epoch>
14596    where
14597        F: FnMut() -> Result<()>,
14598    {
14599        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
14600    }
14601
14602    fn rename_table_with_epoch_inner(
14603        &self,
14604        name: &str,
14605        new_name: &str,
14606        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14607    ) -> Result<Epoch> {
14608        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14609            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14610        {
14611            return Err(MongrelError::InvalidArgument(
14612                "the CTAS building-table namespace is reserved".into(),
14613            ));
14614        }
14615        self.rename_table_with_state(name, new_name, false, None, before_commit)
14616    }
14617
14618    /// Atomically publish a hidden CTAS build under its intended live name.
14619    #[doc(hidden)]
14620    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14621        self.publish_building_table_inner(build_name, new_name, None)
14622    }
14623
14624    #[doc(hidden)]
14625    pub fn publish_building_table_controlled<F>(
14626        &self,
14627        build_name: &str,
14628        new_name: &str,
14629        mut before_commit: F,
14630    ) -> Result<Epoch>
14631    where
14632        F: FnMut() -> Result<()>,
14633    {
14634        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
14635    }
14636
14637    fn publish_building_table_inner(
14638        &self,
14639        build_name: &str,
14640        new_name: &str,
14641        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14642    ) -> Result<Epoch> {
14643        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14644            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14645        {
14646            return Err(MongrelError::InvalidArgument(
14647                "invalid CTAS publish identity".into(),
14648            ));
14649        }
14650        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
14651    }
14652
14653    /// Atomically publish a hidden build and its materialized-view definition.
14654    #[doc(hidden)]
14655    pub fn publish_materialized_building_table(
14656        &self,
14657        build_name: &str,
14658        new_name: &str,
14659        definition: crate::catalog::MaterializedViewEntry,
14660    ) -> Result<Epoch> {
14661        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
14662    }
14663
14664    #[doc(hidden)]
14665    pub fn publish_materialized_building_table_controlled<F>(
14666        &self,
14667        build_name: &str,
14668        new_name: &str,
14669        definition: crate::catalog::MaterializedViewEntry,
14670        mut before_commit: F,
14671    ) -> Result<Epoch>
14672    where
14673        F: FnMut() -> Result<()>,
14674    {
14675        self.publish_materialized_building_table_inner(
14676            build_name,
14677            new_name,
14678            definition,
14679            Some(&mut before_commit),
14680        )
14681    }
14682
14683    fn publish_materialized_building_table_inner(
14684        &self,
14685        build_name: &str,
14686        new_name: &str,
14687        definition: crate::catalog::MaterializedViewEntry,
14688        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14689    ) -> Result<Epoch> {
14690        if definition.name != new_name || definition.query.trim().is_empty() {
14691            return Err(MongrelError::InvalidArgument(
14692                "invalid materialized-view publication".into(),
14693            ));
14694        }
14695        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
14696    }
14697
14698    /// Atomically replace a still-live table with its completed hidden rebuild.
14699    #[doc(hidden)]
14700    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14701        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
14702    }
14703
14704    #[doc(hidden)]
14705    pub fn publish_rebuilding_table_controlled<F>(
14706        &self,
14707        build_name: &str,
14708        new_name: &str,
14709        mut before_commit: F,
14710    ) -> Result<Epoch>
14711    where
14712        F: FnMut() -> Result<()>,
14713    {
14714        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
14715    }
14716
14717    /// Atomically replace a live materialized-view table and its definition.
14718    #[doc(hidden)]
14719    pub fn publish_materialized_rebuilding_table_controlled<F>(
14720        &self,
14721        build_name: &str,
14722        new_name: &str,
14723        definition: crate::catalog::MaterializedViewEntry,
14724        mut before_commit: F,
14725    ) -> Result<Epoch>
14726    where
14727        F: FnMut() -> Result<()>,
14728    {
14729        self.publish_rebuilding_table_inner(
14730            build_name,
14731            new_name,
14732            Some(definition),
14733            Some(&mut before_commit),
14734        )
14735    }
14736
14737    fn publish_rebuilding_table_inner(
14738        &self,
14739        build_name: &str,
14740        new_name: &str,
14741        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14742        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14743    ) -> Result<Epoch> {
14744        use crate::wal::DdlOp;
14745
14746        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14747            || new_name.is_empty()
14748            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14749        {
14750            return Err(MongrelError::InvalidArgument(
14751                "invalid rebuilding-table publish identity".into(),
14752            ));
14753        }
14754        if materialized_view.as_ref().is_some_and(|definition| {
14755            definition.name != new_name || definition.query.trim().is_empty()
14756        }) {
14757            return Err(MongrelError::InvalidArgument(
14758                "invalid materialized-view replacement".into(),
14759            ));
14760        }
14761        self.require(&crate::auth::Permission::Ddl)?;
14762        if self.poisoned.load(Ordering::Relaxed) {
14763            return Err(MongrelError::Other(
14764                "database poisoned by fsync error".into(),
14765            ));
14766        }
14767        // S1A-004: admit the DDL as one core operation.
14768        let _operation = self.admit_operation()?;
14769        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14770        let _ddl = self.ddl_lock.lock();
14771        let _security_write = self.security_write()?;
14772        let (table_id, replaced_table_id) = {
14773            let catalog = self.catalog.read();
14774            let build = catalog.building(build_name).ok_or_else(|| {
14775                MongrelError::NotFound(format!("building table {build_name:?} not found"))
14776            })?;
14777            let replaced_table_id = match &build.state {
14778                TableState::Building {
14779                    intended_name,
14780                    replaces_table_id: Some(replaced_table_id),
14781                    ..
14782                } if intended_name == new_name => *replaced_table_id,
14783                _ => {
14784                    return Err(MongrelError::InvalidArgument(format!(
14785                        "building table {build_name:?} is not a replacement for {new_name:?}"
14786                    )))
14787                }
14788            };
14789            if catalog
14790                .live(new_name)
14791                .is_none_or(|entry| entry.table_id != replaced_table_id)
14792            {
14793                return Err(MongrelError::Conflict(format!(
14794                    "table {new_name:?} changed while its replacement was built"
14795                )));
14796            }
14797            (build.table_id, replaced_table_id)
14798        };
14799
14800        let _commit = self.commit_lock.lock();
14801        let epoch = self.epoch.assigned().next();
14802        let txn_id = self.alloc_txn_id()?;
14803        let mut next_catalog = self.catalog.read().clone();
14804        apply_rebuilding_publish(
14805            &mut next_catalog,
14806            table_id,
14807            replaced_table_id,
14808            new_name,
14809            epoch.0,
14810        )?;
14811        if let Some(definition) = materialized_view.as_mut() {
14812            definition.last_refresh_epoch = epoch.0;
14813        }
14814        let materialized_view_json = materialized_view
14815            .as_ref()
14816            .map(DdlOp::encode_materialized_view)
14817            .transpose()?;
14818        if let Some(definition) = materialized_view {
14819            if let Some(existing) = next_catalog
14820                .materialized_views
14821                .iter_mut()
14822                .find(|existing| existing.name == definition.name)
14823            {
14824                *existing = definition;
14825            } else {
14826                next_catalog.materialized_views.push(definition);
14827            }
14828        }
14829        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14830        if let Some(before_commit) = before_commit {
14831            before_commit()?;
14832        }
14833        let assigned_epoch = self.epoch.bump_assigned();
14834        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
14835        if assigned_epoch != epoch {
14836            return Err(MongrelError::Conflict(
14837                "commit epoch changed while sequencer lock was held".into(),
14838            ));
14839        }
14840        let commit_seq = {
14841            let mut wal = self.shared_wal.lock();
14842            let append: Result<u64> = (|| {
14843                wal.append(
14844                    txn_id,
14845                    table_id,
14846                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
14847                        table_id,
14848                        replaced_table_id,
14849                        new_name: new_name.to_string(),
14850                    }),
14851                )?;
14852                if let Some(definition_json) = materialized_view_json {
14853                    wal.append(
14854                        txn_id,
14855                        table_id,
14856                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14857                            name: new_name.to_string(),
14858                            definition_json,
14859                        }),
14860                    )?;
14861                }
14862                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14863                wal.append_commit(txn_id, epoch, &[])
14864            })();
14865            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14866        };
14867        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14868
14869        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14870        self.tables.write().remove(&replaced_table_id);
14871        if let Some(table) = self.tables.read().get(&table_id) {
14872            table.lock().set_catalog_name(new_name.to_string());
14873        }
14874        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
14875        Ok(epoch)
14876    }
14877
14878    fn rename_table_with_state(
14879        &self,
14880        name: &str,
14881        new_name: &str,
14882        building: bool,
14883        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14884        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14885    ) -> Result<Epoch> {
14886        use crate::wal::DdlOp;
14887        use std::sync::atomic::Ordering;
14888
14889        self.require(&crate::auth::Permission::Ddl)?;
14890        if self.poisoned.load(Ordering::Relaxed) {
14891            return Err(MongrelError::Other(
14892                "database poisoned by fsync error".into(),
14893            ));
14894        }
14895
14896        // A no-op rename short-circuits before any locking, so it can never
14897        // trip the "target already exists" check (the source *is* that name).
14898        if name == new_name {
14899            return Ok(self.visible_epoch());
14900        }
14901        if new_name.is_empty() {
14902            return Err(MongrelError::InvalidArgument(
14903                "rename_table: new name must not be empty".into(),
14904            ));
14905        }
14906        // S1A-004: admit the DDL as one core operation.
14907        let _operation = self.admit_operation()?;
14908        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14909        let _g = self.ddl_lock.lock();
14910        let _security_write = self.security_write()?;
14911        self.require(&crate::auth::Permission::Ddl)?;
14912        let table_id = {
14913            let cat = self.catalog.read();
14914            let src = if building {
14915                cat.building(name)
14916            } else {
14917                cat.live(name)
14918            }
14919            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14920            if building
14921                && !matches!(
14922                    &src.state,
14923                    TableState::Building { intended_name, .. } if intended_name == new_name
14924                )
14925            {
14926                return Err(MongrelError::InvalidArgument(format!(
14927                    "building table {name:?} is not reserved for {new_name:?}"
14928                )));
14929            }
14930            // Target must be free. Checked under ddl_lock, which every other
14931            // DDL (create/rename/drop) also holds, so a concurrent operation
14932            // cannot claim `new_name` between this check and the catalog write.
14933            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
14934                return Err(MongrelError::InvalidArgument(format!(
14935                    "rename_table: a table named {new_name:?} already exists"
14936                )));
14937            }
14938            src.table_id
14939        };
14940
14941        let commit_lock = Arc::clone(&self.commit_lock);
14942        let _c = commit_lock.lock();
14943        let epoch = self.epoch.bump_assigned();
14944        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14945        let txn_id = self.alloc_txn_id()?;
14946        if let Some(definition) = materialized_view.as_mut() {
14947            definition.last_refresh_epoch = epoch.0;
14948        }
14949        let materialized_view_json = materialized_view
14950            .as_ref()
14951            .map(DdlOp::encode_materialized_view)
14952            .transpose()?;
14953        let mut next_catalog = self.catalog.read().clone();
14954        if building {
14955            // CTAS building-table publishes stay on the legacy mutation: the
14956            // command model's `RenameTable` resolves live tables only.
14957            let entry = next_catalog
14958                .tables
14959                .iter_mut()
14960                .find(|t| t.table_id == table_id)
14961                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14962            entry.name = new_name.to_string();
14963            entry.state = TableState::Live;
14964            for trigger in &mut next_catalog.triggers {
14965                if matches!(
14966                    &trigger.trigger.target,
14967                    TriggerTarget::Table(target) if target == name
14968                ) {
14969                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
14970                }
14971            }
14972            if let Some(definition) = next_catalog
14973                .materialized_views
14974                .iter_mut()
14975                .find(|definition| definition.name == name)
14976            {
14977                definition.name = new_name.to_string();
14978            }
14979            if let Some(definition) = materialized_view.take() {
14980                next_catalog.materialized_views.push(definition);
14981            }
14982            for table in &mut next_catalog.security.rls_tables {
14983                if table == name {
14984                    *table = new_name.to_string();
14985                }
14986            }
14987            for policy in &mut next_catalog.security.policies {
14988                if policy.table == name {
14989                    policy.table = new_name.to_string();
14990                }
14991            }
14992            for mask in &mut next_catalog.security.masks {
14993                if mask.table == name {
14994                    mask.table = new_name.to_string();
14995                }
14996            }
14997            for role in &mut next_catalog.roles {
14998                for permission in &mut role.permissions {
14999                    rename_permission_table(permission, name, new_name);
15000                }
15001            }
15002            advance_security_version(&mut next_catalog)?;
15003        } else {
15004            // S1F-001: a plain live-table rename is a versioned catalog
15005            // command (the delta mirrors the legacy mutation above, including
15006            // trigger retargets and table-scoped security state).
15007            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
15008                name: name.to_string(),
15009                new_name: new_name.to_string(),
15010                at_epoch: epoch.0,
15011            };
15012            self.apply_catalog_command_to(&mut next_catalog, command)?;
15013        }
15014        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15015        let ddl = if building {
15016            DdlOp::PublishBuildingTable {
15017                table_id,
15018                new_name: new_name.to_string(),
15019            }
15020        } else {
15021            DdlOp::RenameTable {
15022                table_id,
15023                new_name: new_name.to_string(),
15024            }
15025        };
15026        let commit_seq = {
15027            let mut wal = self.shared_wal.lock();
15028            if let Some(before_commit) = before_commit {
15029                before_commit()?;
15030            }
15031            let append: Result<u64> = (|| {
15032                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
15033                if let Some(definition_json) = materialized_view_json {
15034                    wal.append(
15035                        txn_id,
15036                        table_id,
15037                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
15038                            name: new_name.to_string(),
15039                            definition_json,
15040                        }),
15041                    )?;
15042                }
15043                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15044                wal.append_commit(txn_id, epoch, &[])
15045            })();
15046            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15047        };
15048        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15049
15050        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
15051        // The in-memory table object is keyed by table_id, not name, so it does
15052        // not move and live TableHandles remain valid.
15053        if let Some(table) = self.tables.read().get(&table_id) {
15054            table.lock().set_catalog_name(new_name.to_string());
15055        }
15056        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
15057        Ok(epoch)
15058    }
15059
15060    /// Add a column through the database catalog and shared WAL.
15061    ///
15062    /// This is the catalog-aware counterpart to [`Table::add_column`]. The
15063    /// mounted table schema, catalog image, and recovery record advance as one
15064    /// durable DDL commit.
15065    pub fn add_column(
15066        &self,
15067        table_name: &str,
15068        name: &str,
15069        ty: TypeId,
15070        flags: ColumnFlags,
15071        default_value: Option<crate::schema::DefaultExpr>,
15072    ) -> Result<ColumnDef> {
15073        self.add_column_with_id(table_name, name, ty, flags, default_value, None)
15074    }
15075
15076    /// Add a catalog-aware column with an optional caller-assigned stable id.
15077    pub fn add_column_with_id(
15078        &self,
15079        table_name: &str,
15080        name: &str,
15081        ty: TypeId,
15082        flags: ColumnFlags,
15083        default_value: Option<crate::schema::DefaultExpr>,
15084        requested_id: Option<u16>,
15085    ) -> Result<ColumnDef> {
15086        use crate::wal::DdlOp;
15087        use std::sync::atomic::Ordering;
15088
15089        self.require(&crate::auth::Permission::Ddl)?;
15090        if self.poisoned.load(Ordering::Relaxed) {
15091            return Err(MongrelError::Other(
15092                "database poisoned by fsync error".into(),
15093            ));
15094        }
15095        let _operation = self.admit_operation()?;
15096        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15097        let _ddl = self.ddl_lock.lock();
15098        let _security_write = self.security_write()?;
15099        self.require(&crate::auth::Permission::Ddl)?;
15100        let table_id = {
15101            let catalog = self.catalog.read();
15102            catalog
15103                .live(table_name)
15104                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15105                .table_id
15106        };
15107        let handle =
15108            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15109                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15110            })?;
15111        let durable_epoch = std::cell::Cell::new(None);
15112        let result: Result<ColumnDef> = (|| {
15113            let mut table = handle.lock();
15114            let (column, prepared_schema) =
15115                table.prepare_add_column(name, ty, flags, default_value, requested_id)?;
15116            let command = crate::catalog_cmds::CatalogCommand::AddColumn {
15117                table: table_name.to_string(),
15118                column: column.clone(),
15119            };
15120            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15121
15122            let commit_lock = Arc::clone(&self.commit_lock);
15123            let _commit = commit_lock.lock();
15124            let epoch = self.epoch.bump_assigned();
15125            let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15126            let txn_id = self.alloc_txn_id()?;
15127            let column_json = DdlOp::encode_column(&column)?;
15128            let mut next_catalog = self.catalog.read().clone();
15129            let catalog_entry_index = next_catalog
15130                .tables
15131                .iter()
15132                .position(|entry| entry.table_id == table_id)
15133                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15134            self.apply_catalog_command_to(&mut next_catalog, command)?;
15135            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15136            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15137            let commit_seq = {
15138                let mut wal = self.shared_wal.lock();
15139                let append: Result<u64> = (|| {
15140                    wal.append(
15141                        txn_id,
15142                        table_id,
15143                        crate::wal::Op::Ddl(DdlOp::AlterTable {
15144                            table_id,
15145                            column_json,
15146                        }),
15147                    )?;
15148                    append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15149                    wal.append_commit(txn_id, epoch, &[])
15150                })();
15151                append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15152            };
15153            let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15154            durable_epoch.set(Some(epoch));
15155
15156            table.apply_altered_schema_prepared(prepared_schema);
15157            let schema = table.schema().clone();
15158            let table_checkpoint = table.checkpoint_altered_schema();
15159            drop(table);
15160            next_catalog.tables[catalog_entry_index].schema = schema;
15161            let catalog_result =
15162                catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15163            *self.catalog.write() = next_catalog;
15164            self.publish_committed(&receipt, epoch)?;
15165            epoch_guard.disarm();
15166            if let Err(error) = table_checkpoint.and(catalog_result) {
15167                self.poisoned.store(true, Ordering::Relaxed);
15168                self.lifecycle.poison();
15169                return Err(MongrelError::DurableCommit {
15170                    epoch: epoch.0,
15171                    message: error.to_string(),
15172                });
15173            }
15174            Ok(column)
15175        })();
15176        result.map_err(|error| match (durable_epoch.get(), error) {
15177            (_, error @ MongrelError::DurableCommit { .. }) => error,
15178            (Some(epoch), error) => MongrelError::DurableCommit {
15179                epoch: epoch.0,
15180                message: error.to_string(),
15181            },
15182            (None, error) => error,
15183        })
15184    }
15185
15186    pub fn alter_column(
15187        &self,
15188        table_name: &str,
15189        column_name: &str,
15190        change: AlterColumn,
15191    ) -> Result<ColumnDef> {
15192        self.alter_column_with_epoch(table_name, column_name, change)
15193            .map(|(column, _)| column)
15194    }
15195
15196    pub fn alter_column_with_epoch(
15197        &self,
15198        table_name: &str,
15199        column_name: &str,
15200        change: AlterColumn,
15201    ) -> Result<(ColumnDef, Option<Epoch>)> {
15202        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
15203    }
15204
15205    /// Cooperatively prepare an ALTER and fence each durable commit separately.
15206    /// `after_commit(Some(epoch))` follows an exact durable outcome;
15207    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
15208    /// for every successful `before_commit` callback.
15209    pub fn alter_column_with_epoch_controlled<B, A>(
15210        &self,
15211        table_name: &str,
15212        column_name: &str,
15213        change: AlterColumn,
15214        control: &crate::ExecutionControl,
15215        mut before_commit: B,
15216        mut after_commit: A,
15217    ) -> Result<(ColumnDef, Option<Epoch>)>
15218    where
15219        B: FnMut() -> Result<()>,
15220        A: FnMut(Option<Epoch>) -> Result<()>,
15221    {
15222        self.alter_column_with_epoch_inner(
15223            table_name,
15224            column_name,
15225            change,
15226            Some(control),
15227            Some(&mut before_commit),
15228            Some(&mut after_commit),
15229        )
15230    }
15231
15232    #[allow(clippy::too_many_arguments)]
15233    fn alter_column_with_epoch_inner(
15234        &self,
15235        table_name: &str,
15236        column_name: &str,
15237        change: AlterColumn,
15238        control: Option<&crate::ExecutionControl>,
15239        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
15240        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
15241    ) -> Result<(ColumnDef, Option<Epoch>)> {
15242        use crate::wal::DdlOp;
15243        use std::sync::atomic::Ordering;
15244
15245        self.require(&crate::auth::Permission::Ddl)?;
15246        commit_prepare_checkpoint(control, 0)?;
15247        if self.poisoned.load(Ordering::Relaxed) {
15248            return Err(MongrelError::Other(
15249                "database poisoned by fsync error".into(),
15250            ));
15251        }
15252        // S1A-004: admit the DDL as one core operation.
15253        let _operation = self.admit_operation()?;
15254        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15255        let _g = self.ddl_lock.lock();
15256        let table_id = {
15257            let cat = self.catalog.read();
15258            cat.live(table_name)
15259                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15260                .table_id
15261        };
15262        let handle =
15263            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15264                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15265            })?;
15266
15267        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
15268        // declared default, backfill existing NULL/absent cells as one durable
15269        // transaction before logging the metadata change. A crash between the
15270        // two commits leaves a harmless nullable-but-filled column; retry is
15271        // idempotent because only remaining NULLs are touched.
15272        let backfill = {
15273            let table = handle.lock();
15274            let old = table
15275                .schema()
15276                .column(column_name)
15277                .cloned()
15278                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
15279            let next_flags = change.flags.unwrap_or(old.flags);
15280            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
15281                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
15282                && old.default_value.is_some()
15283            {
15284                let snapshot = self.snapshot_for_epoch(self.epoch.visible());
15285                let mut updates = Vec::new();
15286                let rows = match control {
15287                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
15288                    None => table.visible_rows(snapshot)?,
15289                };
15290                for (row_index, row) in rows.into_iter().enumerate() {
15291                    commit_prepare_checkpoint(control, row_index)?;
15292                    if row
15293                        .columns
15294                        .get(&old.id)
15295                        .is_some_and(|value| !matches!(value, Value::Null))
15296                    {
15297                        continue;
15298                    }
15299                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
15300                    table.apply_defaults(&mut cells)?;
15301                    updates.push((
15302                        table_id,
15303                        crate::txn::Staged::Update {
15304                            row_id: row.row_id,
15305                            new_row: cells,
15306                            changed_columns: vec![old.id],
15307                        },
15308                    ));
15309                }
15310                updates
15311            } else {
15312                Vec::new()
15313            }
15314        };
15315        let durable_epoch = std::cell::Cell::new(None);
15316        let backfill_epoch = if backfill.is_empty() {
15317            None
15318        } else {
15319            let (principal, catalog_bound) = self.transaction_principal_snapshot();
15320            let txn_id = self.alloc_txn_id()?;
15321            let mut entered_fence = false;
15322            let commit_result = match (control, before_commit.as_deref_mut()) {
15323                (Some(control), Some(before_commit)) => self
15324                    .commit_transaction_with_external_states_controlled(
15325                        txn_id,
15326                        self.epoch.visible(),
15327                        backfill,
15328                        Vec::new(),
15329                        Vec::new(),
15330                        principal.clone(),
15331                        catalog_bound,
15332                        None,
15333                        crate::txn::TxnCommitContext::internal(),
15334                        control,
15335                        &mut || {
15336                            before_commit()?;
15337                            entered_fence = true;
15338                            Ok(())
15339                        },
15340                    )
15341                    .map(|(epoch, _)| epoch),
15342                _ => self
15343                    .commit_transaction_with_external_states(
15344                        txn_id,
15345                        self.epoch.visible(),
15346                        backfill,
15347                        Vec::new(),
15348                        Vec::new(),
15349                        principal,
15350                        catalog_bound,
15351                        None,
15352                        crate::txn::TxnCommitContext::internal(),
15353                    )
15354                    .map(|(epoch, _)| epoch),
15355            };
15356            let commit_result = if entered_fence {
15357                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15358            } else {
15359                commit_result
15360            };
15361            match &commit_result {
15362                Ok(epoch) => durable_epoch.set(Some(*epoch)),
15363                Err(MongrelError::DurableCommit { epoch, .. }) => {
15364                    durable_epoch.set(Some(Epoch(*epoch)));
15365                }
15366                Err(_) => {}
15367            }
15368            Some(commit_result?)
15369        };
15370        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
15371            let _security_write = self.security_write()?;
15372            self.require(&crate::auth::Permission::Ddl)?;
15373            if self
15374                .catalog
15375                .read()
15376                .live(table_name)
15377                .is_none_or(|entry| entry.table_id != table_id)
15378            {
15379                return Err(MongrelError::Conflict(format!(
15380                    "table {table_name:?} changed during ALTER"
15381                )));
15382            }
15383            let mut table = handle.lock();
15384            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
15385            let renamed_column = (column.name != column_name).then(|| column.name.clone());
15386            let Some(prepared_schema) = prepared_schema else {
15387                return Ok((column, backfill_epoch));
15388            };
15389            // S1F-001: the schema mutation is a versioned catalog command. It
15390            // validates pure against the current catalog before an epoch is
15391            // consumed; the engine's post-apply schema (schema_id bump
15392            // included) is the resolved delta the wrapper publishes.
15393            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
15394                table: table_name.to_string(),
15395                column: column.clone(),
15396            };
15397            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15398
15399            let commit_lock = Arc::clone(&self.commit_lock);
15400            let _c = commit_lock.lock();
15401            let epoch = self.epoch.bump_assigned();
15402            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15403            let txn_id = self.alloc_txn_id()?;
15404            let column_json = DdlOp::encode_column(&column)?;
15405            let mut next_catalog = self.catalog.read().clone();
15406            let catalog_entry_index = next_catalog
15407                .tables
15408                .iter()
15409                .position(|entry| entry.table_id == table_id)
15410                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15411            if let Some(new_column_name) = &renamed_column {
15412                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
15413                    commit_prepare_checkpoint(control, trigger_index)?;
15414                    if matches!(
15415                        &trigger.trigger.target,
15416                        TriggerTarget::Table(target) if target == table_name
15417                    ) {
15418                        trigger.trigger = trigger.trigger.renamed_update_column(
15419                            column_name,
15420                            new_column_name.clone(),
15421                            epoch.0,
15422                        )?;
15423                    }
15424                }
15425                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
15426                    commit_prepare_checkpoint(control, role_index)?;
15427                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
15428                        commit_prepare_checkpoint(control, permission_index)?;
15429                        rename_permission_column(
15430                            permission,
15431                            table_name,
15432                            column_name,
15433                            new_column_name,
15434                        );
15435                    }
15436                }
15437                advance_security_version(&mut next_catalog)?;
15438            }
15439            // Record the versioned command (validating again against the
15440            // candidate), then install the engine-resolved schema image:
15441            // identical to the command's delta when the mounted table and the
15442            // catalog are in sync, and byte-for-byte what the legacy inline
15443            // mutation published either way.
15444            self.apply_catalog_command_to(&mut next_catalog, command)?;
15445            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15446            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15447            commit_prepare_checkpoint(control, 0)?;
15448            let mut entered_fence = false;
15449            if let Some(before_commit) = before_commit.as_deref_mut() {
15450                before_commit()?;
15451                entered_fence = true;
15452            }
15453            let commit_result: Result<Epoch> = (|| {
15454                let commit_seq = {
15455                    let mut wal = self.shared_wal.lock();
15456                    let append: Result<u64> = (|| {
15457                        wal.append(
15458                            txn_id,
15459                            table_id,
15460                            crate::wal::Op::Ddl(DdlOp::AlterTable {
15461                                table_id,
15462                                column_json,
15463                            }),
15464                        )?;
15465                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15466                        wal.append_commit(txn_id, epoch, &[])
15467                    })();
15468                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15469                };
15470                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15471                durable_epoch.set(Some(epoch));
15472
15473                table.apply_altered_schema_prepared(prepared_schema);
15474                let schema = table.schema().clone();
15475                let table_checkpoint = table.checkpoint_altered_schema();
15476                drop(table);
15477                next_catalog.tables[catalog_entry_index].schema = schema;
15478                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15479                let catalog_result =
15480                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15481                let security_version = next_catalog.security_version;
15482                *self.catalog.write() = next_catalog;
15483                if renamed_column.is_some() {
15484                    self.security_coordinator
15485                        .version
15486                        .store(security_version, Ordering::Release);
15487                }
15488                self.publish_committed(&receipt, epoch)?;
15489                _epoch_guard.disarm();
15490                if let Err(error) = table_checkpoint.and(catalog_result) {
15491                    self.poisoned.store(true, Ordering::Relaxed);
15492                    self.lifecycle.poison();
15493                    return Err(MongrelError::DurableCommit {
15494                        epoch: epoch.0,
15495                        message: error.to_string(),
15496                    });
15497                }
15498                Ok(epoch)
15499            })();
15500            let commit_result = if entered_fence {
15501                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15502            } else {
15503                commit_result
15504            };
15505            let epoch = commit_result?;
15506            Ok((column, Some(epoch)))
15507        })();
15508        result.map_err(|error| match (durable_epoch.get(), error) {
15509            (_, error @ MongrelError::DurableCommit { .. }) => error,
15510            (Some(epoch), error) => MongrelError::DurableCommit {
15511                epoch: epoch.0,
15512                message: error.to_string(),
15513            },
15514            (None, error) => error,
15515        })
15516    }
15517
15518    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
15519    /// replication. Duration is in nanoseconds.
15520    pub fn set_table_ttl(
15521        &self,
15522        table_name: &str,
15523        column_name: &str,
15524        duration_nanos: u64,
15525    ) -> Result<crate::manifest::TtlPolicy> {
15526        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
15527        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
15528    }
15529
15530    /// Set TTL metadata on a hidden build before it is published.
15531    #[doc(hidden)]
15532    pub fn set_building_table_ttl(
15533        &self,
15534        table_name: &str,
15535        column_name: &str,
15536        duration_nanos: u64,
15537    ) -> Result<crate::manifest::TtlPolicy> {
15538        let policy = self.replace_table_ttl_with_state(
15539            table_name,
15540            Some((column_name, duration_nanos)),
15541            true,
15542        )?;
15543        policy
15544            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
15545    }
15546
15547    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
15548        self.replace_table_ttl(table_name, None)?;
15549        Ok(())
15550    }
15551
15552    fn replace_table_ttl(
15553        &self,
15554        table_name: &str,
15555        requested: Option<(&str, u64)>,
15556    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15557        self.replace_table_ttl_with_state(table_name, requested, false)
15558    }
15559
15560    fn replace_table_ttl_with_state(
15561        &self,
15562        table_name: &str,
15563        requested: Option<(&str, u64)>,
15564        building: bool,
15565    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15566        use crate::wal::DdlOp;
15567        use std::sync::atomic::Ordering;
15568
15569        self.require(&crate::auth::Permission::Ddl)?;
15570        if self.poisoned.load(Ordering::Relaxed) {
15571            return Err(MongrelError::Other(
15572                "database poisoned by fsync error".into(),
15573            ));
15574        }
15575
15576        let _g = self.ddl_lock.lock();
15577        let _security_write = self.security_write()?;
15578        self.require(&crate::auth::Permission::Ddl)?;
15579        let table_id = {
15580            let cat = self.catalog.read();
15581            if building {
15582                cat.building(table_name)
15583            } else {
15584                cat.live(table_name)
15585            }
15586            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15587            .table_id
15588        };
15589        let handle =
15590            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15591                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15592            })?;
15593        let mut table = handle.lock();
15594        let policy = match requested {
15595            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
15596            None => None,
15597        };
15598        if table.ttl() == policy {
15599            return Ok(policy);
15600        }
15601
15602        let commit_lock = Arc::clone(&self.commit_lock);
15603        let _c = commit_lock.lock();
15604        let epoch = self.epoch.bump_assigned();
15605        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15606        let txn_id = self.alloc_txn_id()?;
15607        let policy_json = DdlOp::encode_ttl(policy)?;
15608        let mut next_catalog = self.catalog.read().clone();
15609        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15610        let commit_seq = {
15611            let mut wal = self.shared_wal.lock();
15612            let append: Result<u64> = (|| {
15613                wal.append(
15614                    txn_id,
15615                    table_id,
15616                    crate::wal::Op::Ddl(DdlOp::SetTtl {
15617                        table_id,
15618                        policy_json,
15619                    }),
15620                )?;
15621                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15622                wal.append_commit(txn_id, epoch, &[])
15623            })();
15624            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15625        };
15626        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15627
15628        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
15629        drop(table);
15630        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
15631            publish_error.get_or_insert(error);
15632        }
15633        self.finish_durable_publish(
15634            epoch,
15635            &mut _epoch_guard,
15636            &receipt,
15637            publish_error.map_or(Ok(()), Err),
15638        )?;
15639        Ok(policy)
15640    }
15641
15642    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
15643    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
15644    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
15645    ///
15646    /// Returns the number of items reclaimed.
15647    pub fn gc(&self) -> Result<usize> {
15648        let control = crate::ExecutionControl::new(None);
15649        self.gc_controlled(&control, || true)
15650    }
15651
15652    /// Discover reclaimable state cooperatively, then cross one publication
15653    /// boundary immediately before the first irreversible deletion.
15654    #[doc(hidden)]
15655    pub fn gc_controlled<F>(
15656        &self,
15657        control: &crate::ExecutionControl,
15658        before_publish: F,
15659    ) -> Result<usize>
15660    where
15661        F: FnOnce() -> bool,
15662    {
15663        self.gc_controlled_with_receipt(control, before_publish)
15664            .map(|(reclaimed, _)| reclaimed)
15665    }
15666
15667    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
15668    /// return that snapshot if an irreversible deletion was attempted.
15669    #[doc(hidden)]
15670    pub fn gc_controlled_with_receipt<F>(
15671        &self,
15672        control: &crate::ExecutionControl,
15673        before_publish: F,
15674    ) -> Result<(usize, Option<MaintenanceReceipt>)>
15675    where
15676        F: FnOnce() -> bool,
15677    {
15678        enum Candidate {
15679            Directory(PathBuf),
15680            File(PathBuf),
15681        }
15682
15683        self.require(&crate::auth::Permission::Ddl)?;
15684        // S1A-004: admit the maintenance pass as one core operation.
15685        let _operation = self.admit_operation()?;
15686        let _ddl = self.ddl_lock.lock();
15687        self.require(&crate::auth::Permission::Ddl)?;
15688        control.checkpoint()?;
15689        let maintenance_epoch = self.epoch.visible();
15690        let min_active = self.snapshots.min_active(maintenance_epoch).0;
15691        let mut candidates = Vec::new();
15692
15693        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
15694        let cat = self.catalog.read();
15695        for (entry_index, entry) in cat.tables.iter().enumerate() {
15696            if entry_index % 256 == 0 {
15697                control.checkpoint()?;
15698            }
15699            if let TableState::Dropped { at_epoch } = entry.state {
15700                if at_epoch <= min_active {
15701                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
15702                    if tdir.exists() {
15703                        candidates.push(Candidate::Directory(tdir));
15704                    }
15705                }
15706            }
15707        }
15708        drop(cat);
15709
15710        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
15711        // in-flight spill's dir (deleting it would lose the pending run and fail
15712        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
15713        // skip any id still registered in `active_spills`.
15714        let cat = self.catalog.read();
15715        for (entry_index, entry) in cat.tables.iter().enumerate() {
15716            if entry_index % 256 == 0 {
15717                control.checkpoint()?;
15718            }
15719            if !matches!(entry.state, TableState::Live) {
15720                continue;
15721            }
15722            let txn_dir = self
15723                .root
15724                .join(TABLES_DIR)
15725                .join(entry.table_id.to_string())
15726                .join("_txn");
15727            if !txn_dir.exists() {
15728                continue;
15729            }
15730            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
15731                if sub_index % 256 == 0 {
15732                    control.checkpoint()?;
15733                }
15734                let sub = sub?;
15735                let name = sub.file_name();
15736                let Some(name) = name.to_str() else { continue };
15737                // A non-numeric entry can't belong to a live txn — sweep it.
15738                let is_active = name
15739                    .parse::<u64>()
15740                    .map(|id| self.active_spills.is_active(id))
15741                    .unwrap_or(false);
15742                if is_active {
15743                    continue;
15744                }
15745                candidates.push(Candidate::Directory(sub.path()));
15746            }
15747        }
15748        drop(cat);
15749
15750        let external_names = {
15751            let cat = self.catalog.read();
15752            cat.external_tables
15753                .iter()
15754                .map(|entry| entry.name.clone())
15755                .collect::<std::collections::HashSet<_>>()
15756        };
15757        let vtab_dir = self.root.join(VTAB_DIR);
15758        if vtab_dir.exists() {
15759            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
15760                if entry_index % 256 == 0 {
15761                    control.checkpoint()?;
15762                }
15763                let entry = entry?;
15764                let name = entry.file_name();
15765                let Some(name) = name.to_str() else { continue };
15766                if external_names.contains(name) {
15767                    continue;
15768                }
15769                let path = entry.path();
15770                if path.is_dir() {
15771                    candidates.push(Candidate::Directory(path));
15772                } else {
15773                    candidates.push(Candidate::File(path));
15774                }
15775            }
15776        }
15777
15778        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
15779        // can still need (spec §6.4). Each table deletes its own retired files
15780        // gated on `min_active` and persists its manifest.
15781        let tables = self
15782            .tables
15783            .read()
15784            .iter()
15785            .map(|(table_id, handle)| (*table_id, handle.clone()))
15786            .collect::<Vec<_>>();
15787        let mut retiring = Vec::new();
15788        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
15789            if table_index % 256 == 0 {
15790                control.checkpoint()?;
15791            }
15792            let backup_pinned: HashSet<u128> = self
15793                .backup_pins
15794                .lock()
15795                .keys()
15796                .filter_map(|(pinned_table, run_id)| {
15797                    (*pinned_table == *table_id).then_some(*run_id)
15798                })
15799                .collect();
15800            if handle
15801                .lock()
15802                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
15803            {
15804                retiring.push((handle.clone(), backup_pinned));
15805            }
15806        }
15807
15808        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
15809        // segment on every reopen without truncating the prior ones, so rotated
15810        // segments accumulate. Once every live table's committed data is durable
15811        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
15812        // (non-active) segments are redundant for recovery and safe to delete —
15813        // an in-flight txn only ever appends to the active segment, which is
15814        // never deleted.
15815        let all_durable = self.active_spills.is_idle()
15816            && tables.iter().all(|(_, handle)| {
15817                let g = handle.lock();
15818                g.memtable_len() == 0 && g.mutable_run_len() == 0
15819            });
15820        let retain = self
15821            .replication_wal_retention_segments
15822            .load(std::sync::atomic::Ordering::Relaxed);
15823        let reap_wal = all_durable
15824            && self
15825                .shared_wal
15826                .lock()
15827                .has_gc_segments_retain_recent(retain)?;
15828
15829        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
15830            return Ok((0, None));
15831        }
15832        control.checkpoint()?;
15833        if !before_publish() {
15834            return Err(MongrelError::Cancelled);
15835        }
15836
15837        let mut reclaimed = 0;
15838        for candidate in candidates {
15839            match candidate {
15840                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
15841                Candidate::File(path) => std::fs::remove_file(path)?,
15842            }
15843            reclaimed += 1;
15844        }
15845        for (handle, backup_pinned) in retiring {
15846            reclaimed += handle
15847                .lock()
15848                .reap_retiring(Epoch(min_active), &backup_pinned)?;
15849        }
15850        if reap_wal {
15851            reclaimed += self
15852                .shared_wal
15853                .lock()
15854                .gc_segments_retain_recent(u64::MAX, retain)?;
15855        }
15856
15857        Ok((
15858            reclaimed,
15859            Some(MaintenanceReceipt {
15860                epoch: maintenance_epoch,
15861            }),
15862        ))
15863    }
15864
15865    /// Produce a deterministic-stable byte image of the database directory.
15866    ///
15867    /// After `checkpoint()`:
15868    ///   - All pending writes are flushed to sorted runs (no memtable data).
15869    ///   - Each table is compacted to a single sorted run (no run fragmentation).
15870    ///   - All non-active WAL segments are deleted (data is durable in runs).
15871    ///   - The active WAL segment is rotated to a fresh empty segment.
15872    ///   - Dropped-table directories are removed.
15873    ///   - All manifests + catalog are persisted.
15874    ///
15875    /// The resulting directory is byte-stable: `git add` captures a snapshot
15876    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
15877    /// no unbounded segment growth, no mutable-run spill files.
15878    ///
15879    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
15880    /// It does NOT clear the exclusive lock — the caller still owns the
15881    /// database handle.
15882    pub fn checkpoint(&self) -> Result<()> {
15883        self.checkpoint_controlled(|| Ok(()))
15884    }
15885
15886    /// Strict checkpoint with a deterministic test hook after every table is
15887    /// flushed/compacted but before WAL replacement.
15888    #[doc(hidden)]
15889    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
15890    where
15891        F: FnOnce() -> Result<()>,
15892    {
15893        self.require(&crate::auth::Permission::Ddl)?;
15894        // S1A-004: admit the checkpoint as one core operation.
15895        let _operation = self.admit_operation()?;
15896        // Block cross-table commits and DDL for the full operation. Locking all
15897        // mounted handles also excludes direct `Table` commits, which do not
15898        // enter the database replication barrier.
15899        let _replication = self.replication_barrier.write();
15900        let _ddl = self.ddl_lock.lock();
15901        let _security = self.security_coordinator.gate.read();
15902        self.require(&crate::auth::Permission::Ddl)?;
15903
15904        let mut handles = self
15905            .tables
15906            .read()
15907            .iter()
15908            .map(|(table_id, handle)| (*table_id, handle.clone()))
15909            .collect::<Vec<_>>();
15910        handles.sort_by_key(|(table_id, _)| *table_id);
15911        let mut tables = handles
15912            .iter()
15913            .map(|(table_id, handle)| (*table_id, handle.lock()))
15914            .collect::<Vec<_>>();
15915
15916        // Strict flush. Any error leaves the old WAL recovery source intact.
15917        for (_, table) in &mut tables {
15918            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
15919            {
15920                table.force_flush()?;
15921            }
15922        }
15923
15924        // Strict compaction. Checkpoint never reports a stable image after a
15925        // skipped failure.
15926        for (_, table) in &mut tables {
15927            if table.run_count() >= 2 || table.should_compact() {
15928                table.compact()?;
15929            }
15930        }
15931
15932        before_wal_reset()?;
15933
15934        // Reap table-local retired runs while every table remains quiesced.
15935        let maintenance_epoch = self.epoch.visible();
15936        let min_active = self.snapshots.min_active(maintenance_epoch);
15937        for (table_id, table) in &mut tables {
15938            let backup_pinned: HashSet<u128> = self
15939                .backup_pins
15940                .lock()
15941                .keys()
15942                .filter_map(|(pinned_table, run_id)| {
15943                    (*pinned_table == *table_id).then_some(*run_id)
15944                })
15945                .collect();
15946            table.reap_retiring(min_active, &backup_pinned)?;
15947        }
15948
15949        // Publish a fresh synced active WAL, then durably reap every older
15950        // segment. This point is reached only after every strict flush succeeds.
15951        self.shared_wal.lock().reset_after_checkpoint()?;
15952
15953        // Remove catalog-unreachable directories and stale transaction state.
15954        let catalog_snapshot = self.catalog.read().clone();
15955        for entry in &catalog_snapshot.tables {
15956            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
15957                crate::durable_file::remove_directory_all(
15958                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
15959                )?;
15960            }
15961            if !matches!(entry.state, TableState::Live) {
15962                continue;
15963            }
15964            let transaction_dir = self
15965                .root
15966                .join(TABLES_DIR)
15967                .join(entry.table_id.to_string())
15968                .join("_txn");
15969            if transaction_dir.is_dir() {
15970                for child in std::fs::read_dir(&transaction_dir)? {
15971                    let child = child?;
15972                    let active = child
15973                        .file_name()
15974                        .to_str()
15975                        .and_then(|name| name.parse::<u64>().ok())
15976                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
15977                    if !active {
15978                        crate::durable_file::remove_directory_all(&child.path())?;
15979                    }
15980                }
15981            }
15982        }
15983        let external_names = catalog_snapshot
15984            .external_tables
15985            .iter()
15986            .map(|entry| entry.name.as_str())
15987            .collect::<HashSet<_>>();
15988        let external_root = self.root.join(VTAB_DIR);
15989        if external_root.is_dir() {
15990            for entry in std::fs::read_dir(&external_root)? {
15991                let entry = entry?;
15992                let name = entry.file_name();
15993                if name
15994                    .to_str()
15995                    .is_some_and(|name| external_names.contains(name))
15996                {
15997                    continue;
15998                }
15999                if entry.file_type()?.is_dir() {
16000                    crate::durable_file::remove_directory_all(&entry.path())?;
16001                } else {
16002                    std::fs::remove_file(entry.path())?;
16003                    crate::durable_file::sync_directory(&external_root)?;
16004                }
16005            }
16006        }
16007
16008        // Final authoritative metadata checkpoint while all writers remain
16009        // excluded.
16010        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
16011        let visible = self.epoch.visible();
16012        for (_, table) in &tables {
16013            table.persist_manifest(visible)?;
16014        }
16015
16016        Ok(())
16017    }
16018    fn alloc_txn_id(&self) -> Result<u64> {
16019        self.ensure_owner_process()?;
16020        crate::txn::allocate_txn_id(&self.next_txn_id)
16021    }
16022
16023    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
16024    /// (or other multi-statement lock holds). Released via
16025    /// [`Self::release_txn_locks`].
16026    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
16027        self.alloc_txn_id()
16028    }
16029
16030    /// Set the per-table spill threshold (bytes). When a transaction's staged
16031    /// bytes for a single table exceed this, the rows are written as a
16032    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
16033    pub fn set_spill_threshold(&self, bytes: u64) {
16034        self.spill_threshold
16035            .store(bytes, std::sync::atomic::Ordering::Relaxed);
16036    }
16037
16038    /// Test-only: install a hook invoked after a transaction writes its spill
16039    /// runs but before the sequencer, so a test can race `gc()` against an
16040    /// in-flight spill. Not part of the stable API.
16041    #[doc(hidden)]
16042    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16043        *self.spill_hook.lock() = Some(Box::new(f));
16044    }
16045
16046    /// Test-only: install a hook invoked while a spilled commit holds the
16047    /// security read gate and before it appends to the WAL.
16048    #[doc(hidden)]
16049    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16050        *self.security_commit_hook.lock() = Some(Box::new(f));
16051    }
16052
16053    /// Test-only: install a hook after transaction preparation and before the
16054    /// commit sequencer validates catalog generations.
16055    #[doc(hidden)]
16056    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16057        *self.catalog_commit_hook.lock() = Some(Box::new(f));
16058    }
16059
16060    /// Test-only: pause an online backup after its consistent boundary is
16061    /// captured but before the pinned immutable runs are copied.
16062    #[doc(hidden)]
16063    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16064        *self.backup_hook.lock() = Some(Box::new(f));
16065    }
16066
16067    /// Test-only: invoked after each successful FK parent-protection lock
16068    /// acquisition during constraint validation, so tests can rendezvous two
16069    /// committing transactions into a deterministic wait-for cycle.
16070    #[doc(hidden)]
16071    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16072        *self.fk_lock_hook.lock() = Some(Arc::new(f));
16073    }
16074
16075    /// Test-only: pause WAL extraction before its final principal recheck.
16076    #[doc(hidden)]
16077    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16078        *self.replication_hook.lock() = Some(Box::new(f));
16079    }
16080
16081    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
16082    /// this stays well below the number of committed transactions when commits
16083    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
16084    #[doc(hidden)]
16085    pub fn __wal_group_sync_count(&self) -> u64 {
16086        self.shared_wal.lock().group_sync_count()
16087    }
16088
16089    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
16090    /// contract that an fsync error would trigger in production.
16091    #[doc(hidden)]
16092    pub fn __poison(&self) {
16093        self.poisoned
16094            .store(true, std::sync::atomic::Ordering::Relaxed);
16095    }
16096
16097    /// Verify multi-table integrity (spec §16). For every live table this:
16098    /// authenticates the manifest; opens each `RunRef`'s file through
16099    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
16100    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
16101    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
16102    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
16103    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
16104    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
16105    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
16106    ///
16107    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
16108    /// full body, so this is an integrity tool, not a hot path.
16109    pub fn check(&self) -> Vec<CheckIssue> {
16110        match self.check_inner(None) {
16111            Ok(issues) => issues,
16112            Err(error) => vec![CheckIssue {
16113                table_id: WAL_TABLE_ID,
16114                table_name: "shared WAL".into(),
16115                severity: "error".into(),
16116                description: error.to_string(),
16117            }],
16118        }
16119    }
16120
16121    /// Integrity check with cooperative cancellation between tables and runs.
16122    #[doc(hidden)]
16123    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
16124        self.check_inner(Some(control))
16125    }
16126
16127    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
16128        let mut issues = Vec::new();
16129        let io_root = self.durable_root.io_path()?;
16130        let cat = self.catalog.read();
16131        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
16132        for (table_index, entry) in cat.tables.iter().enumerate() {
16133            if table_index % 256 == 0 {
16134                if let Some(control) = control {
16135                    control.checkpoint()?;
16136                }
16137            }
16138            if !matches!(entry.state, TableState::Live) {
16139                continue;
16140            }
16141            let tdir = io_root.join(TABLES_DIR).join(entry.table_id.to_string());
16142            let mut err = |sev: &str, desc: String| {
16143                issues.push(CheckIssue {
16144                    table_id: entry.table_id,
16145                    table_name: entry.name.clone(),
16146                    severity: sev.into(),
16147                    description: desc,
16148                });
16149            };
16150            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
16151                Ok(m) => m,
16152                Err(e) => {
16153                    err("error", format!("manifest read failed: {e}"));
16154                    continue;
16155                }
16156            };
16157            if m.flushed_epoch > m.current_epoch {
16158                err(
16159                    "error",
16160                    format!(
16161                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
16162                        m.flushed_epoch, m.current_epoch
16163                    ),
16164                );
16165            }
16166
16167            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
16168            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
16169            for (run_index, rr) in m.runs.iter().enumerate() {
16170                if run_index % 256 == 0 {
16171                    if let Some(control) = control {
16172                        control.checkpoint()?;
16173                    }
16174                }
16175                referenced.insert(rr.run_id);
16176                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
16177                if !run_path.exists() {
16178                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
16179                    continue;
16180                }
16181                match crate::sorted_run::RunReader::open(
16182                    &run_path,
16183                    entry.schema.clone(),
16184                    self.kek.clone(),
16185                ) {
16186                    Ok(reader) => {
16187                        if reader.row_count() as u64 != rr.row_count {
16188                            err(
16189                                "error",
16190                                format!(
16191                                    "run r-{} row count mismatch: manifest {} vs run {}",
16192                                    rr.run_id,
16193                                    rr.row_count,
16194                                    reader.row_count()
16195                                ),
16196                            );
16197                        }
16198                    }
16199                    Err(e) => {
16200                        err(
16201                            "error",
16202                            format!("run r-{} integrity check failed: {e}", rr.run_id),
16203                        );
16204                    }
16205                }
16206            }
16207
16208            // Compaction-superseded runs awaiting retention-gated deletion are
16209            // tracked in `retiring`; their files are expected on disk, so they
16210            // are not orphans.
16211            for r in &m.retiring {
16212                referenced.insert(r.run_id);
16213            }
16214
16215            // Orphan `.sr` files present on disk but absent from the manifest.
16216            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
16217                for (entry_index, ent) in rd.flatten().enumerate() {
16218                    if entry_index % 256 == 0 {
16219                        if let Some(control) = control {
16220                            control.checkpoint()?;
16221                        }
16222                    }
16223                    let p = ent.path();
16224                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
16225                        continue;
16226                    }
16227                    let run_id = p
16228                        .file_stem()
16229                        .and_then(|s| s.to_str())
16230                        .and_then(|s| s.strip_prefix("r-"))
16231                        .and_then(|s| s.parse::<u128>().ok());
16232                    if let Some(id) = run_id {
16233                        if !referenced.contains(&id) {
16234                            err(
16235                                "warning",
16236                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
16237                            );
16238                        }
16239                    }
16240                }
16241            }
16242        }
16243
16244        let external_names = cat
16245            .external_tables
16246            .iter()
16247            .map(|entry| entry.name.clone())
16248            .collect::<std::collections::HashSet<_>>();
16249        let vtab_dir = io_root.join(VTAB_DIR);
16250        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
16251            for (entry_index, entry) in entries.flatten().enumerate() {
16252                if entry_index % 256 == 0 {
16253                    if let Some(control) = control {
16254                        control.checkpoint()?;
16255                    }
16256                }
16257                let name = entry.file_name();
16258                let Some(name) = name.to_str() else { continue };
16259                if !external_names.contains(name) {
16260                    issues.push(CheckIssue {
16261                        table_id: EXTERNAL_TABLE_ID,
16262                        table_name: name.to_string(),
16263                        severity: "warning".into(),
16264                        description: format!(
16265                            "orphan external table state entry {:?} not referenced by the catalog",
16266                            entry.path()
16267                        ),
16268                    });
16269                }
16270            }
16271        }
16272
16273        // WAL retention / integrity invariant (spec §16): every on-disk WAL
16274        // segment must open (header magic + version, and the frame cipher must
16275        // be derivable for an encrypted WAL). A segment that won't open is
16276        // corrupt or truncated and would break crash recovery. `table_id` is
16277        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
16278        // never confuses a WAL issue with a real table.
16279        if let Some(control) = control {
16280            control.checkpoint()?;
16281        }
16282        for (seg, msg) in self.shared_wal.lock().verify_segments() {
16283            issues.push(CheckIssue {
16284                table_id: WAL_TABLE_ID,
16285                table_name: "<wal>".into(),
16286                severity: "error".into(),
16287                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
16288            });
16289        }
16290        Ok(issues)
16291    }
16292
16293    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
16294    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
16295    /// unmounts them from the live table map so the DB still opens.
16296    pub fn doctor(&self) -> Result<Vec<u64>> {
16297        let control = crate::ExecutionControl::new(None);
16298        self.doctor_controlled(&control, || true)
16299    }
16300
16301    /// Check cancellably, then fence immediately before the first quarantine
16302    /// mutation. Returning `false` from `before_publish` leaves the database
16303    /// untouched.
16304    #[doc(hidden)]
16305    pub fn doctor_controlled<F>(
16306        &self,
16307        control: &crate::ExecutionControl,
16308        before_publish: F,
16309    ) -> Result<Vec<u64>>
16310    where
16311        F: FnOnce() -> bool,
16312    {
16313        self.doctor_controlled_with_receipt(control, before_publish)
16314            .map(|(quarantined, _)| quarantined)
16315    }
16316
16317    /// Check cancellably and return the exact catalog epoch used for a
16318    /// quarantine publication. No receipt is returned when nothing changes.
16319    #[doc(hidden)]
16320    pub fn doctor_controlled_with_receipt<F>(
16321        &self,
16322        control: &crate::ExecutionControl,
16323        before_publish: F,
16324    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
16325    where
16326        F: FnOnce() -> bool,
16327    {
16328        // Hold the DDL lock for the whole operation to prevent concurrent
16329        // create_table/drop_table from racing the catalog/dir mutation.
16330        let _ddl = self.ddl_lock.lock();
16331        let _security_write = self.security_write()?;
16332        let issues = self.check_inner(Some(control))?;
16333        // A corrupt WAL segment is reported as an error but is NOT a table
16334        // problem — quarantining an innocent table cannot fix it (and the first
16335        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
16336        // them disjoint). The admin must address WAL corruption manually.
16337        let bad_tables: std::collections::HashSet<u64> = issues
16338            .iter()
16339            .filter(|i| {
16340                i.severity == "error"
16341                    && i.table_id != WAL_TABLE_ID
16342                    && i.table_id != EXTERNAL_TABLE_ID
16343            })
16344            .map(|i| i.table_id)
16345            .collect();
16346        if bad_tables.is_empty() {
16347            return Ok((Vec::new(), None));
16348        }
16349        let _commit = self.commit_lock.lock();
16350        control.checkpoint()?;
16351        if !before_publish() {
16352            return Err(MongrelError::Cancelled);
16353        }
16354        let maintenance_epoch = self.epoch.bump_assigned();
16355        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
16356
16357        let qdir = self.root.join("_quarantine");
16358        crate::durable_file::create_directory(&qdir)?;
16359        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
16360        bad_tables.sort_unstable();
16361
16362        // Quiesce every mounted target before catalog publication. Existing
16363        // handle clones are marked unavailable in the publication callback so
16364        // they cannot append to the shared WAL after their catalog entry drops.
16365        let mut handles = self
16366            .tables
16367            .read()
16368            .iter()
16369            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
16370            .map(|(table_id, handle)| (*table_id, handle.clone()))
16371            .collect::<Vec<_>>();
16372        handles.sort_by_key(|(table_id, _)| *table_id);
16373        let mut table_guards = handles
16374            .iter()
16375            .map(|(table_id, handle)| (*table_id, handle.lock()))
16376            .collect::<Vec<_>>();
16377
16378        let mut next_catalog = self.catalog.read().clone();
16379        for table_id in &bad_tables {
16380            if let Some(entry) = next_catalog
16381                .tables
16382                .iter_mut()
16383                .find(|entry| entry.table_id == *table_id)
16384            {
16385                entry.state = TableState::Dropped {
16386                    at_epoch: maintenance_epoch.0,
16387                };
16388            }
16389        }
16390        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
16391
16392        let txn_id = self.alloc_txn_id()?;
16393        let commit_seq = {
16394            let mut wal = self.shared_wal.lock();
16395            let append: Result<u64> = (|| {
16396                for table_id in &bad_tables {
16397                    wal.append(
16398                        txn_id,
16399                        *table_id,
16400                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
16401                            table_id: *table_id,
16402                        }),
16403                    )?;
16404                }
16405                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
16406                wal.append_commit(txn_id, maintenance_epoch, &[])
16407            })();
16408            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
16409        };
16410        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
16411        for (_, table) in &mut table_guards {
16412            table.mark_unavailable_after_quarantine();
16413        }
16414        {
16415            let mut live_tables = self.tables.write();
16416            for table_id in &bad_tables {
16417                live_tables.remove(table_id);
16418            }
16419        }
16420        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
16421        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
16422
16423        // Release DOCTOR's own table guards and handle clones before moving
16424        // the directory. Windows refuses to rename files held open by the
16425        // final mounted Table instance.
16426        drop(table_guards);
16427        drop(handles);
16428
16429        // The catalog drop is durable. Directory placement is secondary but
16430        // still uses a write-through rename. A failure reports the known
16431        // catalog outcome and leaves a harmless orphan under `tables/`.
16432        for table_id in &bad_tables {
16433            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
16434            if source.exists() {
16435                let destination = qdir.join(table_id.to_string());
16436                if let Err(error) = crate::durable_file::rename(&source, &destination) {
16437                    return Err(MongrelError::DurableCommit {
16438                        epoch: maintenance_epoch.0,
16439                        message: format!(
16440                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
16441                        ),
16442                    });
16443                }
16444            }
16445        }
16446        Ok((
16447            bad_tables,
16448            Some(MaintenanceReceipt {
16449                epoch: maintenance_epoch,
16450            }),
16451        ))
16452    }
16453
16454    /// The DB-wide KEK (if encrypted).
16455    #[allow(dead_code)]
16456    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
16457        self.kek.as_ref()
16458    }
16459
16460    /// Shared epoch authority (used by the transaction layer in P2).
16461    #[allow(dead_code)]
16462    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
16463        &self.epoch
16464    }
16465
16466    /// Shared snapshot registry (used by GC in P3.6).
16467    #[allow(dead_code)]
16468    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
16469        &self.snapshots
16470    }
16471}
16472
16473fn external_state_dir(root: &Path, name: &str) -> PathBuf {
16474    root.join(VTAB_DIR).join(name)
16475}
16476
16477fn append_catalog_snapshot(
16478    wal: &mut crate::wal::SharedWal,
16479    txn_id: u64,
16480    catalog: &Catalog,
16481) -> Result<()> {
16482    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
16483    wal.append(
16484        txn_id,
16485        WAL_TABLE_ID,
16486        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
16487    )?;
16488    Ok(())
16489}
16490
16491fn filter_ignored_staging(
16492    staging: Vec<(u64, crate::txn::Staged)>,
16493    ignored_indices: &std::collections::BTreeSet<usize>,
16494) -> Vec<(u64, crate::txn::Staged)> {
16495    if ignored_indices.is_empty() {
16496        return staging;
16497    }
16498    staging
16499        .into_iter()
16500        .enumerate()
16501        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
16502        .collect()
16503}
16504
16505fn external_state_file(root: &Path, name: &str) -> PathBuf {
16506    external_state_dir(root, name).join("state.json")
16507}
16508
16509fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
16510    let path = external_state_file(root, name);
16511    match std::fs::read(path) {
16512        Ok(bytes) => Ok(bytes),
16513        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
16514        Err(e) => Err(e.into()),
16515    }
16516}
16517
16518fn current_external_state_bytes(
16519    root: &Path,
16520    external_states: &[(String, Vec<u8>)],
16521    name: &str,
16522) -> Result<Vec<u8>> {
16523    for (table, state) in external_states.iter().rev() {
16524        if table == name {
16525            return Ok(state.clone());
16526        }
16527    }
16528    read_external_state_file(root, name)
16529}
16530
16531fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
16532    let mut out = external_states;
16533    dedup_external_states_in_place(&mut out);
16534    out
16535}
16536
16537fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
16538    let mut seen = std::collections::HashSet::new();
16539    let mut out = Vec::with_capacity(external_states.len());
16540    for (name, state) in std::mem::take(external_states).into_iter().rev() {
16541        if seen.insert(name.clone()) {
16542            out.push((name, state));
16543        }
16544    }
16545    out.reverse();
16546    *external_states = out;
16547}
16548
16549fn prepare_external_state_file(
16550    root: &Path,
16551    name: &str,
16552    state: &[u8],
16553    txn_id: u64,
16554) -> Result<PathBuf> {
16555    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
16556    let dir = external_state_dir(root, name);
16557    crate::durable_file::create_directory(&dir)?;
16558    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
16559    {
16560        let mut file = std::fs::OpenOptions::new()
16561            .create_new(true)
16562            .write(true)
16563            .open(&pending)?;
16564        file.write_all(state)?;
16565        file.sync_all()?;
16566    }
16567    Ok(pending)
16568}
16569
16570fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
16571    let path = external_state_file(root, name);
16572    crate::durable_file::replace(pending, &path)?;
16573    Ok(())
16574}
16575
16576fn write_external_state_file(
16577    durable: &crate::durable_file::DurableRoot,
16578    name: &str,
16579    state: &[u8],
16580) -> Result<()> {
16581    let directory = Path::new(VTAB_DIR).join(name);
16582    durable.create_directory_all(&directory)?;
16583    durable.write_atomic(directory.join("state.json"), state)?;
16584    Ok(())
16585}
16586
16587fn validate_recovered_data_table(
16588    catalog: &Catalog,
16589    tables: &HashMap<u64, TableHandle>,
16590    table_id: u64,
16591    commit_epoch: u64,
16592    offset: u64,
16593) -> Result<bool> {
16594    let entry = catalog
16595        .tables
16596        .iter()
16597        .find(|entry| entry.table_id == table_id)
16598        .ok_or_else(|| MongrelError::CorruptWal {
16599            offset,
16600            reason: format!("committed record references unknown table {table_id}"),
16601        })?;
16602    if commit_epoch < entry.created_epoch {
16603        return Err(MongrelError::CorruptWal {
16604            offset,
16605            reason: format!(
16606                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16607                entry.created_epoch
16608            ),
16609        });
16610    }
16611    match entry.state {
16612        TableState::Dropped { at_epoch } => {
16613            // Abandoned hidden builds are marked dropped at the last durable
16614            // boundary during open, so their final build commit may equal the
16615            // cleanup epoch. Ordinary table drops consume a new epoch and must
16616            // remain strictly later than every data commit.
16617            let abandoned_build_boundary =
16618                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16619            if commit_epoch >= at_epoch && !abandoned_build_boundary {
16620                Err(MongrelError::CorruptWal {
16621                    offset,
16622                    reason: format!(
16623                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16624                    ),
16625                })
16626            } else {
16627                Ok(false)
16628            }
16629        }
16630        TableState::Live | TableState::Building { .. } => {
16631            if tables.contains_key(&table_id) {
16632                Ok(true)
16633            } else {
16634                Err(MongrelError::CorruptWal {
16635                    offset,
16636                    reason: format!("live table {table_id} has no mounted recovery handle"),
16637                })
16638            }
16639        }
16640    }
16641}
16642
16643type RecoveryTableStage = (
16644    Vec<crate::memtable::Row>,
16645    Vec<(crate::rowid::RowId, Epoch)>,
16646    Option<Epoch>,
16647    Epoch,
16648);
16649
16650#[derive(Clone)]
16651struct RecoveryValidationTable {
16652    schema: Schema,
16653    flushed_epoch: u64,
16654}
16655
16656fn validate_shared_wal_recovery_plan(
16657    durable_root: &crate::durable_file::DurableRoot,
16658    catalog: &Catalog,
16659    recovered_table_ids: &HashSet<u64>,
16660    reconciled_table_ids: &HashSet<u64>,
16661    meta_dek: Option<&[u8; META_DEK_LEN]>,
16662    kek: Option<Arc<crate::encryption::Kek>>,
16663    records: &[crate::wal::Record],
16664) -> Result<()> {
16665    use crate::wal::{DdlOp, Op};
16666
16667    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
16668    for entry in &catalog.tables {
16669        if !matches!(entry.state, TableState::Live) {
16670            continue;
16671        }
16672        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
16673        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
16674            Ok(manifest) => Some(manifest),
16675            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
16676            Err(error) => return Err(error),
16677        };
16678        let flushed_epoch = if let Some(manifest) = manifest {
16679            if manifest.table_id != entry.table_id {
16680                return Err(MongrelError::Conflict(format!(
16681                    "catalog table {} storage identity mismatch",
16682                    entry.table_id
16683                )));
16684            }
16685            if (manifest.schema_id != entry.schema.schema_id
16686                && !reconciled_table_ids.contains(&entry.table_id))
16687                || manifest.flushed_epoch > manifest.current_epoch
16688                || manifest.global_idx_epoch > manifest.current_epoch
16689                || manifest.next_row_id == u64::MAX
16690                || manifest.auto_inc_next < 0
16691                || manifest.auto_inc_next == i64::MAX
16692                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
16693            {
16694                return Err(MongrelError::InvalidArgument(format!(
16695                    "table {} manifest counters or schema identity are invalid",
16696                    entry.table_id
16697                )));
16698            }
16699            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
16700            crate::global_idx::read_durable_for(
16701                durable_root,
16702                &relative_dir,
16703                entry.table_id,
16704                &entry.schema,
16705                idx_dek.as_deref(),
16706            )?;
16707            let mut run_ids = HashSet::new();
16708            let mut maximum_row_id = None::<u64>;
16709            for run in &manifest.runs {
16710                if run.run_id >= u64::MAX as u128
16711                    || run.epoch_created > manifest.current_epoch
16712                    || !run_ids.insert(run.run_id)
16713                {
16714                    return Err(MongrelError::InvalidArgument(format!(
16715                        "table {} manifest contains an invalid or duplicate run id",
16716                        entry.table_id
16717                    )));
16718                }
16719                let relative = relative_dir
16720                    .join(crate::engine::RUNS_DIR)
16721                    .join(format!("r-{}.sr", run.run_id as u64));
16722                let file = durable_root.open_regular(&relative)?;
16723                let mut reader = crate::sorted_run::RunReader::open_file(
16724                    file,
16725                    entry.schema.clone(),
16726                    kek.clone(),
16727                )?;
16728                let header = reader.header();
16729                if header.run_id != run.run_id
16730                    || header.level != run.level
16731                    || header.row_count != run.row_count
16732                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
16733                    || header.is_uniform_epoch() && header.epoch_created != 0
16734                    || header.schema_id > entry.schema.schema_id
16735                {
16736                    return Err(MongrelError::InvalidArgument(format!(
16737                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
16738                        entry.table_id,
16739                        run.run_id,
16740                        header.run_id,
16741                        header.level,
16742                        header.row_count,
16743                        header.epoch_created,
16744                        header.schema_id,
16745                        run.run_id,
16746                        run.level,
16747                        run.row_count,
16748                        run.epoch_created,
16749                        entry.schema.schema_id,
16750                    )));
16751                }
16752                if header.row_count != 0 {
16753                    maximum_row_id = Some(
16754                        maximum_row_id
16755                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
16756                    );
16757                }
16758                reader.validate_all_pages()?;
16759            }
16760            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
16761                return Err(MongrelError::InvalidArgument(format!(
16762                    "table {} next_row_id does not advance beyond persisted rows",
16763                    entry.table_id
16764                )));
16765            }
16766            for run in &manifest.retiring {
16767                if run.run_id >= u64::MAX as u128
16768                    || run.retire_epoch > manifest.current_epoch
16769                    || !run_ids.insert(run.run_id)
16770                {
16771                    return Err(MongrelError::InvalidArgument(format!(
16772                        "table {} manifest contains an invalid or aliased retired run",
16773                        entry.table_id
16774                    )));
16775                }
16776            }
16777            manifest.flushed_epoch
16778        } else {
16779            if !recovered_table_ids.contains(&entry.table_id) {
16780                return Err(MongrelError::NotFound(format!(
16781                    "live table {} manifest is missing",
16782                    entry.table_id
16783                )));
16784            }
16785            0
16786        };
16787        tables.insert(
16788            entry.table_id,
16789            RecoveryValidationTable {
16790                schema: entry.schema.clone(),
16791                flushed_epoch,
16792            },
16793        );
16794    }
16795
16796    let committed = records
16797        .iter()
16798        .filter_map(|record| match record.op {
16799            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
16800            _ => None,
16801        })
16802        .collect::<HashMap<_, _>>();
16803    let mut run_ids = HashSet::new();
16804    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
16805    for record in records {
16806        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
16807            continue;
16808        };
16809        match &record.op {
16810            Op::Put { table_id, rows } => {
16811                let table = validate_recovery_data_table_plan(
16812                    catalog,
16813                    &tables,
16814                    *table_id,
16815                    commit_epoch,
16816                    record.seq.0,
16817                )?;
16818                let decoded: Vec<crate::memtable::Row> =
16819                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
16820                        offset: record.seq.0,
16821                        reason: format!(
16822                            "committed Put payload for transaction {} could not be decoded: {error}",
16823                            record.txn_id
16824                        ),
16825                    })?;
16826                if let Some(table) = table {
16827                    for row in &decoded {
16828                        if !recovered_row_ids
16829                            .entry(*table_id)
16830                            .or_default()
16831                            .insert(row.row_id.0)
16832                        {
16833                            return Err(MongrelError::CorruptWal {
16834                                offset: record.seq.0,
16835                                reason: format!(
16836                                    "committed WAL repeats recovered row id {} for table {table_id}",
16837                                    row.row_id.0
16838                                ),
16839                            });
16840                        }
16841                        validate_recovered_row(&table.schema, row)?;
16842                    }
16843                }
16844            }
16845            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
16846                validate_recovery_data_table_plan(
16847                    catalog,
16848                    &tables,
16849                    *table_id,
16850                    commit_epoch,
16851                    record.seq.0,
16852                )?;
16853            }
16854            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
16855            Op::Ddl(DdlOp::ResetExternalTableState {
16856                name,
16857                generation_epoch,
16858            }) => {
16859                if *generation_epoch != commit_epoch {
16860                    return Err(MongrelError::CorruptWal {
16861                        offset: record.seq.0,
16862                        reason: format!(
16863                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
16864                        ),
16865                    });
16866                }
16867                validate_recovered_external_name(name)?;
16868            }
16869            Op::TxnCommit { added_runs, .. } => {
16870                for added in added_runs {
16871                    let Some(table) = validate_recovery_data_table_plan(
16872                        catalog,
16873                        &tables,
16874                        added.table_id,
16875                        commit_epoch,
16876                        record.seq.0,
16877                    )?
16878                    else {
16879                        continue;
16880                    };
16881                    if added.run_id >= u64::MAX as u128
16882                        || !run_ids.insert((added.table_id, added.run_id))
16883                    {
16884                        return Err(MongrelError::CorruptWal {
16885                            offset: record.seq.0,
16886                            reason: format!(
16887                                "duplicate or invalid recovered run {} for table {}",
16888                                added.run_id, added.table_id
16889                            ),
16890                        });
16891                    }
16892                    if commit_epoch <= table.flushed_epoch {
16893                        continue;
16894                    }
16895                    validate_planned_spilled_run(
16896                        durable_root,
16897                        record.txn_id,
16898                        commit_epoch,
16899                        added,
16900                        &table.schema,
16901                        kek.clone(),
16902                    )?;
16903                }
16904            }
16905            _ => {}
16906        }
16907    }
16908    Ok(())
16909}
16910
16911fn validate_recovery_data_table_plan<'a>(
16912    catalog: &Catalog,
16913    tables: &'a HashMap<u64, RecoveryValidationTable>,
16914    table_id: u64,
16915    commit_epoch: u64,
16916    offset: u64,
16917) -> Result<Option<&'a RecoveryValidationTable>> {
16918    let entry = catalog
16919        .tables
16920        .iter()
16921        .find(|entry| entry.table_id == table_id)
16922        .ok_or_else(|| MongrelError::CorruptWal {
16923            offset,
16924            reason: format!("committed record references unknown table {table_id}"),
16925        })?;
16926    if commit_epoch < entry.created_epoch {
16927        return Err(MongrelError::CorruptWal {
16928            offset,
16929            reason: format!(
16930                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16931                entry.created_epoch
16932            ),
16933        });
16934    }
16935    match entry.state {
16936        TableState::Dropped { at_epoch } => {
16937            let abandoned =
16938                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16939            if commit_epoch >= at_epoch && !abandoned {
16940                return Err(MongrelError::CorruptWal {
16941                    offset,
16942                    reason: format!(
16943                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16944                    ),
16945                });
16946            }
16947            Ok(None)
16948        }
16949        TableState::Live => {
16950            tables
16951                .get(&table_id)
16952                .map(Some)
16953                .ok_or_else(|| MongrelError::CorruptWal {
16954                    offset,
16955                    reason: format!("live table {table_id} has no recovery plan"),
16956                })
16957        }
16958        TableState::Building { .. } => Err(MongrelError::CorruptWal {
16959            offset,
16960            reason: format!("building table {table_id} was not normalized before recovery"),
16961        }),
16962    }
16963}
16964
16965fn validate_planned_spilled_run(
16966    root: &crate::durable_file::DurableRoot,
16967    txn_id: u64,
16968    commit_epoch: u64,
16969    added: &crate::wal::AddedRun,
16970    schema: &Schema,
16971    kek: Option<Arc<crate::encryption::Kek>>,
16972) -> Result<()> {
16973    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
16974    let destination = table
16975        .join(crate::engine::RUNS_DIR)
16976        .join(format!("r-{}.sr", added.run_id as u64));
16977    let pending = table
16978        .join("_txn")
16979        .join(txn_id.to_string())
16980        .join(format!("r-{}.sr", added.run_id as u64));
16981    let file = match root.open_regular(&destination) {
16982        Ok(file) => file,
16983        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
16984            root.open_regular(&pending).map_err(|pending_error| {
16985                if pending_error.kind() == std::io::ErrorKind::NotFound {
16986                    MongrelError::CorruptWal {
16987                        offset: commit_epoch,
16988                        reason: format!(
16989                            "committed spilled run {} for transaction {txn_id} is missing",
16990                            added.run_id
16991                        ),
16992                    }
16993                } else {
16994                    pending_error.into()
16995                }
16996            })?
16997        }
16998        Err(error) => return Err(error.into()),
16999    };
17000    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
17001    let header = reader.header();
17002    if header.run_id != added.run_id
17003        || header.content_hash != added.content_hash
17004        || header.row_count != added.row_count
17005        || header.level != added.level
17006        || header.min_row_id != added.min_row_id
17007        || header.max_row_id != added.max_row_id
17008        || header.schema_id != schema.schema_id
17009        || !header.is_uniform_epoch()
17010        || header.epoch_created != 0
17011    {
17012        return Err(MongrelError::CorruptWal {
17013            offset: commit_epoch,
17014            reason: format!(
17015                "committed spilled run {} metadata differs from WAL",
17016                added.run_id
17017            ),
17018        });
17019    }
17020    reader.validate_all_pages()?;
17021    Ok(())
17022}
17023
17024/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
17025///
17026/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
17027/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
17028/// 2 applies each committed data record (Put/Delete) to its table at the commit
17029/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
17030/// durable in a sorted run). Finally the shared epoch authority is raised to the
17031/// max committed epoch so the next commit continues monotonically.
17032/// The staged-write payload contract of a distributed-transaction write
17033/// intent (spec section 12.8). A participant in two-phase commit stages its
17034/// writes as opaque intent payloads (`WriteIntent::value_ref` in
17035/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
17036/// this engine-defined encoding is the whole contract. At prepare time the
17037/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
17038/// committed resolution they are applied through
17039/// [`Database::apply_staged_txn_writes`].
17040///
17041/// The encoding is bincode over this enum (the same codec the WAL frame
17042/// payloads use); discriminants are never reused.
17043#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17044pub enum StagedTxnWrite {
17045    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
17046    /// identical payload shape an `Op::Put` WAL record carries). Row commit
17047    /// epochs are placeholders — the resolution apply restamps every row at
17048    /// the synthetic commit epoch.
17049    Put {
17050        /// The mounted table the rows target.
17051        table_id: u64,
17052        /// Bincode `Vec<crate::memtable::Row>`.
17053        rows: Vec<u8>,
17054    },
17055    /// Staged row deletes by row id.
17056    Delete {
17057        /// The mounted table the deletes target.
17058        table_id: u64,
17059        /// Row ids (`crate::RowId` values) to delete.
17060        row_ids: Vec<u64>,
17061    },
17062}
17063
17064impl StagedTxnWrite {
17065    /// Serializes deterministically (bincode over the enum).
17066    pub fn encode(&self) -> Result<Vec<u8>> {
17067        Ok(bincode::serialize(self)?)
17068    }
17069
17070    /// Decodes one staged-write payload, failing closed on malformed input.
17071    pub fn decode(bytes: &[u8]) -> Result<Self> {
17072        Ok(bincode::deserialize(bytes)?)
17073    }
17074}
17075
17076/// Leader-side spill translation for the replicated write path (spec section
17077/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
17078///
17079/// A transaction whose staged puts exceed the spill threshold commits with
17080/// its rows in a leader-local sorted run: the commit marker's `added_runs`
17081/// links the run file and the rows also ride the WAL as logical
17082/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
17083/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
17084/// and because the raft entry is already quorum-committed, an apply-time
17085/// rejection wedges the whole group's apply stream. The leader therefore
17086/// translates the staged record sequence **before proposal**:
17087///
17088/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
17089///   (identical row bytes; recovery restamps the rows at the commit epoch),
17090/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
17091///   reach a replica),
17092/// - every other record passes through byte-identical.
17093///
17094/// The standalone commit path is untouched: the leader's own WAL keeps the
17095/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
17096/// links the run; this function reads but never mutates its input.
17097///
17098/// Translation is total for any sequence the commit sequencer actually
17099/// produced. As a fail-closed guard against malformed or truncated captures,
17100/// the sequence is structurally validated (one transaction, exactly one
17101/// trailing commit marker), every spill payload must decode, and every linked
17102/// run's rows must be provably present as logical records (the row-id range
17103/// covers `row_count` rows); a violation rejects the proposal with
17104/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
17105/// post-commit. (Taxonomy: `InvalidArgument` maps to
17106/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
17107/// disagreement that is never retried unchanged. The normative spec category
17108/// for "the commit was not applied; only a fresh transaction may succeed" is
17109/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
17110/// `error.rs`, which is outside this change's file scope — tracked as a
17111/// follow-up.)
17112pub fn translate_records_for_replication(
17113    records: &[crate::wal::Record],
17114) -> Result<Vec<crate::wal::Record>> {
17115    use crate::wal::Op;
17116
17117    // Structural validation mirrors `apply_replicated_records`: one
17118    // transaction, exactly one commit marker, at the tail.
17119    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
17120        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
17121    })?;
17122    if records.iter().any(|record| record.txn_id != txn_id) {
17123        return Err(MongrelError::InvalidArgument(
17124            "replicated transaction payload mixes transaction ids".into(),
17125        ));
17126    }
17127    let commits = records
17128        .iter()
17129        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
17130        .count();
17131    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
17132        return Err(MongrelError::InvalidArgument(
17133            "replicated transaction payload must end in exactly one commit marker".into(),
17134        ));
17135    }
17136
17137    // Decode every logical spill payload now: a payload that cannot decode
17138    // here would fail every replica's apply identically — reject at propose
17139    // time instead.
17140    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
17141    for record in records {
17142        if let Op::SpilledRows { table_id, rows } = &record.op {
17143            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
17144                MongrelError::InvalidArgument(format!(
17145                    "spilled row payload for table {table_id} cannot decode for replication: \
17146                         {error}"
17147                ))
17148            })?;
17149            spilled_rows.entry(*table_id).or_default().extend(chunk);
17150        }
17151    }
17152
17153    // Coverage proof: every run the commit marker links must have its full
17154    // row content present as logical records, or replicas would silently
17155    // lose those rows.
17156    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
17157        unreachable!("one trailing commit marker validated above");
17158    };
17159    for run in added_runs {
17160        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
17161            MongrelError::InvalidArgument(format!(
17162                "commit links spilled run {} for table {} but carries no logical row records \
17163                 for it",
17164                run.run_id, run.table_id
17165            ))
17166        })?;
17167        let covered = rows
17168            .iter()
17169            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
17170            .count() as u64;
17171        if covered != run.row_count {
17172            return Err(MongrelError::InvalidArgument(format!(
17173                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
17174                 row records cover {} rows",
17175                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
17176            )));
17177        }
17178    }
17179
17180    // Translate: spill payloads become ordinary puts; the commit marker no
17181    // longer references leader-local run files.
17182    let translated = records
17183        .iter()
17184        .map(|record| {
17185            let op = match &record.op {
17186                Op::SpilledRows { table_id, rows } => Op::Put {
17187                    table_id: *table_id,
17188                    rows: rows.clone(),
17189                },
17190                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
17191                    epoch: *epoch,
17192                    added_runs: Vec::new(),
17193                },
17194                op => op.clone(),
17195            };
17196            crate::wal::Record::new(record.seq, record.txn_id, op)
17197        })
17198        .collect();
17199    Ok(translated)
17200}
17201
17202fn recover_shared_wal(
17203    durable_root: &crate::durable_file::DurableRoot,
17204    tables: &HashMap<u64, TableHandle>,
17205    catalog: &Catalog,
17206    epoch: &EpochAuthority,
17207    records: &[crate::wal::Record],
17208) -> Result<()> {
17209    use crate::memtable::Row;
17210    use crate::wal::{DdlOp, Op};
17211
17212    // Pass 1: committed-txn outcomes + collect spilled-run info.
17213    let mut committed: HashMap<u64, u64> = HashMap::new();
17214    // Physical HLC micros from Op::CommitTimestamp, keyed by txn_id (P0.5).
17215    let mut commit_ts_by_txn: HashMap<u64, mongreldb_types::hlc::HlcTimestamp> = HashMap::new();
17216    let mut spilled_to_link: Vec<(
17217        u64, /*txn_id*/
17218        u64, /*epoch*/
17219        Vec<crate::wal::AddedRun>,
17220    )> = Vec::new();
17221    for r in records {
17222        match &r.op {
17223            Op::CommitTimestamp { unix_nanos } => {
17224                commit_ts_by_txn.insert(
17225                    r.txn_id,
17226                    mongreldb_types::hlc::HlcTimestamp {
17227                        physical_micros: unix_nanos / 1_000,
17228                        logical: 0,
17229                        node_tiebreaker: 0,
17230                    },
17231                );
17232            }
17233            Op::TxnCommit {
17234                epoch: ce,
17235                ref added_runs,
17236            } => {
17237                committed.insert(r.txn_id, *ce);
17238                if !added_runs.is_empty() {
17239                    spilled_to_link.push((r.txn_id, *ce, added_runs.clone()));
17240                }
17241            }
17242            _ => {}
17243        }
17244    }
17245    for record in records {
17246        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
17247            continue;
17248        };
17249        match &record.op {
17250            Op::Put { table_id, .. }
17251            | Op::Delete { table_id, .. }
17252            | Op::TruncateTable { table_id } => {
17253                validate_recovered_data_table(
17254                    catalog,
17255                    tables,
17256                    *table_id,
17257                    commit_epoch,
17258                    record.seq.0,
17259                )?;
17260            }
17261            Op::TxnCommit { added_runs, .. } => {
17262                for run in added_runs {
17263                    validate_recovered_data_table(
17264                        catalog,
17265                        tables,
17266                        run.table_id,
17267                        commit_epoch,
17268                        record.seq.0,
17269                    )?;
17270                }
17271            }
17272            _ => {}
17273        }
17274    }
17275    let truncated_transactions: HashSet<(u64, u64)> = records
17276        .iter()
17277        .filter_map(|record| {
17278            committed.get(&record.txn_id)?;
17279            match record.op {
17280                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
17281                _ => None,
17282            }
17283        })
17284        .collect();
17285
17286    // Pass 2: stage data per table, gated by flushed_epoch.
17287    enum ExternalRecoveryAction {
17288        Write { name: String, state: Vec<u8> },
17289        Reset { name: String },
17290    }
17291    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
17292    let mut external_actions = Vec::new();
17293    let mut max_epoch = epoch.visible().0;
17294    for r in records.iter().cloned() {
17295        let Some(&ce) = committed.get(&r.txn_id) else {
17296            continue; // aborted / in-flight — discard
17297        };
17298        let commit_epoch = Epoch(ce);
17299        max_epoch = max_epoch.max(ce);
17300        match r.op {
17301            Op::Put { table_id, rows } => {
17302                // Skip if this table already flushed past the commit epoch.
17303                let skip = tables
17304                    .get(&table_id)
17305                    .map(|h| h.lock().flushed_epoch() >= ce)
17306                    .unwrap_or(true);
17307                if skip {
17308                    continue;
17309                }
17310                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
17311                    MongrelError::CorruptWal {
17312                        offset: r.seq.0,
17313                        reason: format!(
17314                            "committed Put payload for transaction {} could not be decoded: {error}",
17315                            r.txn_id
17316                        ),
17317                    }
17318                })?;
17319                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
17320                // at pending_epoch which equals the commit epoch, but be robust).
17321                // P0.5: prefer the durable CommitTimestamp HLC for this txn when
17322                // present so recovery restores HLC-authoritative versions.
17323                let txn_commit_ts = commit_ts_by_txn.get(&r.txn_id).copied();
17324                let rows: Vec<Row> = rows
17325                    .into_iter()
17326                    .map(|mut row| {
17327                        row.committed_epoch = commit_epoch;
17328                        if row.commit_ts.is_none() {
17329                            row.commit_ts = txn_commit_ts;
17330                        }
17331                        row
17332                    })
17333                    .collect();
17334                let entry = stage
17335                    .entry(table_id)
17336                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17337                entry.0.extend(rows);
17338                entry.3 = commit_epoch;
17339            }
17340            Op::Delete { table_id, row_ids } => {
17341                let skip = tables
17342                    .get(&table_id)
17343                    .map(|h| h.lock().flushed_epoch() >= ce)
17344                    .unwrap_or(true);
17345                if skip {
17346                    continue;
17347                }
17348                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
17349                let entry = stage
17350                    .entry(table_id)
17351                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17352                entry.1.extend(dels);
17353                entry.3 = commit_epoch;
17354            }
17355            Op::TruncateTable { table_id } => {
17356                let skip = tables
17357                    .get(&table_id)
17358                    .map(|h| h.lock().flushed_epoch() >= ce)
17359                    .unwrap_or(true);
17360                if skip {
17361                    continue;
17362                }
17363                stage.insert(
17364                    table_id,
17365                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
17366                );
17367            }
17368            Op::ExternalTableState { name, state } => {
17369                let current_generation = catalog
17370                    .external_tables
17371                    .iter()
17372                    .find(|entry| entry.name == name)
17373                    .map(|entry| entry.created_epoch);
17374                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
17375                    validate_recovered_external_name(&name)?;
17376                    external_actions.push(ExternalRecoveryAction::Write { name, state });
17377                }
17378            }
17379            Op::Ddl(DdlOp::ResetExternalTableState {
17380                name,
17381                generation_epoch,
17382            }) => {
17383                if generation_epoch != ce {
17384                    return Err(MongrelError::CorruptWal {
17385                        offset: r.seq.0,
17386                        reason: format!(
17387                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
17388                    ),
17389                    });
17390                }
17391                validate_recovered_external_name(&name)?;
17392                external_actions.push(ExternalRecoveryAction::Reset { name });
17393            }
17394            Op::Flush { .. }
17395            | Op::TxnCommit { .. }
17396            | Op::TxnAbort
17397            | Op::Ddl(_)
17398            | Op::BeforeImage { .. }
17399            | Op::CommitTimestamp { .. }
17400            | Op::SpilledRows { .. } => {}
17401        }
17402    }
17403    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
17404        added_runs.retain(|added| {
17405            tables
17406                .get(&added.table_id)
17407                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
17408        });
17409    }
17410    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
17411    validate_recovery_table_stages(tables, &stage)?;
17412    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
17413
17414    // All WAL payloads, catalog generations, table stages, and immutable run
17415    // identities have now been validated. Only this application phase mutates
17416    // the database tree.
17417    for action in external_actions {
17418        match action {
17419            ExternalRecoveryAction::Write { name, state } => {
17420                write_external_state_file(durable_root, &name, &state)?;
17421            }
17422            ExternalRecoveryAction::Reset { name } => {
17423                durable_root.create_directory_all(VTAB_DIR)?;
17424                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
17425            }
17426        }
17427    }
17428    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
17429        let Some(handle) = tables.get(&table_id) else {
17430            continue;
17431        };
17432        let mut t = handle.lock();
17433        if let Some(epoch) = truncate_epoch {
17434            t.apply_truncate(epoch);
17435        }
17436        t.recover_apply(rows, deletes)?;
17437        // The WAL can be newer than the copied/persisted manifest after a
17438        // crash or replication apply. Rebuild O(1) count metadata from the
17439        // recovered state before endorsing the commit epoch in the manifest.
17440        let rows = t.visible_rows(Snapshot::unbounded())?;
17441        t.live_count = rows.len() as u64;
17442        // Recovery can replay older row commits while a newer spilled run is
17443        // already linked by the copied manifest. Never move that manifest's
17444        // epoch behind its existing run references.
17445        t.persist_manifest(table_epoch.max(epoch.visible()))?;
17446    }
17447
17448    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
17449    // between TxnCommit sync and the publish phase leaves the run in
17450    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
17451    for (txn_id, ce, added_runs) in &spilled_to_link {
17452        for ar in added_runs {
17453            let Some(handle) = tables.get(&ar.table_id) else {
17454                continue;
17455            };
17456            let mut t = handle.lock();
17457            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
17458            let destination = table_dir
17459                .join(crate::engine::RUNS_DIR)
17460                .join(format!("r-{}.sr", ar.run_id));
17461            match durable_root.open_regular(&destination) {
17462                Ok(_) => {}
17463                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17464                    let pending = table_dir
17465                        .join("_txn")
17466                        .join(txn_id.to_string())
17467                        .join(format!("r-{}.sr", ar.run_id));
17468                    durable_root.rename_file_new(&pending, &destination)?;
17469                }
17470                Err(error) => return Err(error.into()),
17471            }
17472            // Only link a run whose file is actually present, and never re-link
17473            // one the publish phase already persisted into the manifest (which is
17474            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
17475            // until segment GC). `recover_spilled_run` is idempotent + reconciles
17476            // `live_count`/indexes only when the run is genuinely new.
17477            let linked = t.recover_spilled_run(crate::manifest::RunRef {
17478                run_id: ar.run_id,
17479                level: ar.level,
17480                epoch_created: *ce,
17481                row_count: ar.row_count,
17482            });
17483            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
17484            if replaced {
17485                t.set_flushed_epoch(Epoch(*ce));
17486            }
17487            if linked || replaced {
17488                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
17489            }
17490        }
17491    }
17492
17493    epoch.advance_recovered(Epoch(max_epoch));
17494    Ok(())
17495}
17496
17497fn reconcile_recovered_table_metadata(
17498    tables: &HashMap<u64, TableHandle>,
17499    epoch: Epoch,
17500) -> Result<()> {
17501    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
17502    table_ids.sort_unstable();
17503    let mut plans = Vec::with_capacity(table_ids.len());
17504    for table_id in &table_ids {
17505        let handle = tables.get(table_id).ok_or_else(|| {
17506            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17507        })?;
17508        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
17509    }
17510    // Every table's data and metadata have been decoded successfully. Publish
17511    // repairs only after the complete database-wide plan is known valid.
17512    for (table_id, plan) in plans {
17513        let handle = tables.get(&table_id).ok_or_else(|| {
17514            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17515        })?;
17516        handle.lock().apply_recovered_metadata(plan, epoch)?;
17517    }
17518    Ok(())
17519}
17520
17521fn validate_recovered_external_name(name: &str) -> Result<()> {
17522    if name.is_empty()
17523        || !name.chars().all(|character| {
17524            character.is_ascii_alphanumeric() || character == '_' || character == '-'
17525        })
17526    {
17527        return Err(MongrelError::CorruptWal {
17528            offset: 0,
17529            reason: format!("unsafe recovered external-table name {name:?}"),
17530        });
17531    }
17532    Ok(())
17533}
17534
17535fn validate_recovery_table_stages(
17536    tables: &HashMap<u64, TableHandle>,
17537    stages: &HashMap<u64, RecoveryTableStage>,
17538) -> Result<()> {
17539    for (table_id, (rows, _, _, _)) in stages {
17540        let handle = tables
17541            .get(table_id)
17542            .ok_or_else(|| MongrelError::CorruptWal {
17543                offset: *table_id,
17544                reason: format!("recovery stage references unmounted table {table_id}"),
17545            })?;
17546        let table = handle.lock();
17547        // Force all existing immutable runs through their integrity/decode path
17548        // before any other table manifest can be changed.
17549        table.visible_rows(Snapshot::unbounded())?;
17550        for row in rows {
17551            validate_recovered_row(table.schema(), row)?;
17552        }
17553    }
17554    Ok(())
17555}
17556
17557fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
17558    if row.deleted || row.row_id.0 == u64::MAX {
17559        return Err(MongrelError::CorruptWal {
17560            offset: row.row_id.0,
17561            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
17562        });
17563    }
17564    let cells = row
17565        .columns
17566        .iter()
17567        .map(|(column, value)| (*column, value.clone()))
17568        .collect::<Vec<_>>();
17569    schema
17570        .validate_persisted_values(&cells)
17571        .map_err(|error| MongrelError::CorruptWal {
17572            offset: row.row_id.0,
17573            reason: format!("recovered row violates table schema: {error}"),
17574        })?;
17575    if schema.auto_increment_column().is_some_and(|column| {
17576        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
17577    }) {
17578        return Err(MongrelError::CorruptWal {
17579            offset: row.row_id.0,
17580            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
17581        });
17582    }
17583    Ok(())
17584}
17585
17586fn validate_recovery_spilled_runs(
17587    root: &crate::durable_file::DurableRoot,
17588    tables: &HashMap<u64, TableHandle>,
17589    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
17590) -> Result<()> {
17591    let mut identities = HashSet::new();
17592    for (txn_id, commit_epoch, added_runs) in spilled {
17593        for added in added_runs {
17594            if added.run_id >= u64::MAX as u128 {
17595                return Err(MongrelError::CorruptWal {
17596                    offset: *commit_epoch,
17597                    reason: format!(
17598                        "recovered run id {} exceeds the on-disk namespace",
17599                        added.run_id
17600                    ),
17601                });
17602            }
17603            let Some(handle) = tables.get(&added.table_id) else {
17604                continue;
17605            };
17606            if !identities.insert((added.table_id, added.run_id)) {
17607                return Err(MongrelError::CorruptWal {
17608                    offset: *commit_epoch,
17609                    reason: format!(
17610                        "duplicate recovered run {} for table {}",
17611                        added.run_id, added.table_id
17612                    ),
17613                });
17614            }
17615            let table = handle.lock();
17616            validate_planned_spilled_run(
17617                root,
17618                *txn_id,
17619                *commit_epoch,
17620                added,
17621                table.schema(),
17622                table.kek(),
17623            )?;
17624        }
17625    }
17626    Ok(())
17627}
17628
17629fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
17630    match condition {
17631        ProcedureCondition::Pk { .. } => {
17632            if schema.primary_key().is_none() {
17633                return Err(MongrelError::InvalidArgument(
17634                    "procedure condition Pk references a table without a primary key".into(),
17635                ));
17636            }
17637        }
17638        ProcedureCondition::BitmapEq { column_id, .. }
17639        | ProcedureCondition::BitmapIn { column_id, .. }
17640        | ProcedureCondition::Range { column_id, .. }
17641        | ProcedureCondition::RangeF64 { column_id, .. }
17642        | ProcedureCondition::IsNull { column_id }
17643        | ProcedureCondition::IsNotNull { column_id }
17644        | ProcedureCondition::FmContains { column_id, .. } => {
17645            validate_column_id(*column_id, schema)?;
17646        }
17647    }
17648    Ok(())
17649}
17650
17651fn bind_procedure_args(
17652    procedure: &StoredProcedure,
17653    mut args: HashMap<String, crate::Value>,
17654) -> Result<HashMap<String, crate::Value>> {
17655    let mut out = HashMap::new();
17656    for param in &procedure.params {
17657        let value = match args.remove(&param.name) {
17658            Some(value) => value,
17659            None => param.default.clone().ok_or_else(|| {
17660                MongrelError::InvalidArgument(format!(
17661                    "missing required procedure parameter {:?}",
17662                    param.name
17663                ))
17664            })?,
17665        };
17666        if !param.nullable && matches!(value, crate::Value::Null) {
17667            return Err(MongrelError::InvalidArgument(format!(
17668                "procedure parameter {:?} must not be NULL",
17669                param.name
17670            )));
17671        }
17672        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
17673            return Err(MongrelError::InvalidArgument(format!(
17674                "procedure parameter {:?} has wrong type",
17675                param.name
17676            )));
17677        }
17678        out.insert(param.name.clone(), value);
17679    }
17680    if let Some(extra) = args.keys().next() {
17681        return Err(MongrelError::InvalidArgument(format!(
17682            "unknown procedure parameter {extra:?}"
17683        )));
17684    }
17685    Ok(out)
17686}
17687
17688fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
17689    matches!(
17690        (value, ty),
17691        (crate::Value::Bool(_), crate::TypeId::Bool)
17692            | (crate::Value::Int64(_), crate::TypeId::Int8)
17693            | (crate::Value::Int64(_), crate::TypeId::Int16)
17694            | (crate::Value::Int64(_), crate::TypeId::Int32)
17695            | (crate::Value::Int64(_), crate::TypeId::Int64)
17696            | (crate::Value::Int64(_), crate::TypeId::UInt8)
17697            | (crate::Value::Int64(_), crate::TypeId::UInt16)
17698            | (crate::Value::Int64(_), crate::TypeId::UInt32)
17699            | (crate::Value::Int64(_), crate::TypeId::UInt64)
17700            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
17701            | (crate::Value::Int64(_), crate::TypeId::Date32)
17702            | (crate::Value::Float64(_), crate::TypeId::Float32)
17703            | (crate::Value::Float64(_), crate::TypeId::Float64)
17704            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
17705            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
17706            | (
17707                crate::Value::GeneratedEmbedding(_),
17708                crate::TypeId::Embedding { .. }
17709            )
17710    )
17711}
17712
17713fn eval_cells(
17714    cells: &[crate::procedure::ProcedureCell],
17715    args: &HashMap<String, crate::Value>,
17716    outputs: &HashMap<String, ProcedureCallOutput>,
17717) -> Result<Vec<(u16, crate::Value)>> {
17718    cells
17719        .iter()
17720        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
17721        .collect()
17722}
17723
17724fn eval_condition(
17725    condition: &ProcedureCondition,
17726    args: &HashMap<String, crate::Value>,
17727    outputs: &HashMap<String, ProcedureCallOutput>,
17728) -> Result<crate::Condition> {
17729    Ok(match condition {
17730        ProcedureCondition::Pk { value } => {
17731            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
17732        }
17733        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
17734            column_id: *column_id,
17735            value: eval_value(value, args, outputs)?.encode_key(),
17736        },
17737        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
17738            column_id: *column_id,
17739            values: values
17740                .iter()
17741                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
17742                .collect::<Result<Vec<_>>>()?,
17743        },
17744        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
17745            column_id: *column_id,
17746            lo: expect_i64(eval_value(lo, args, outputs)?)?,
17747            hi: expect_i64(eval_value(hi, args, outputs)?)?,
17748        },
17749        ProcedureCondition::RangeF64 {
17750            column_id,
17751            lo,
17752            lo_inclusive,
17753            hi,
17754            hi_inclusive,
17755        } => crate::Condition::RangeF64 {
17756            column_id: *column_id,
17757            lo: expect_f64(eval_value(lo, args, outputs)?)?,
17758            lo_inclusive: *lo_inclusive,
17759            hi: expect_f64(eval_value(hi, args, outputs)?)?,
17760            hi_inclusive: *hi_inclusive,
17761        },
17762        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
17763            column_id: *column_id,
17764        },
17765        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
17766            column_id: *column_id,
17767        },
17768        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
17769            column_id: *column_id,
17770            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
17771        },
17772    })
17773}
17774
17775fn eval_value(
17776    value: &ProcedureValue,
17777    args: &HashMap<String, crate::Value>,
17778    outputs: &HashMap<String, ProcedureCallOutput>,
17779) -> Result<crate::Value> {
17780    match value {
17781        ProcedureValue::Literal(value) => Ok(value.clone()),
17782        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
17783            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17784        }),
17785        ProcedureValue::StepScalar(id) => match outputs.get(id) {
17786            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
17787            _ => Err(MongrelError::InvalidArgument(format!(
17788                "procedure step {id:?} did not return a scalar"
17789            ))),
17790        },
17791        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
17792            Err(MongrelError::InvalidArgument(
17793                "row-valued procedure reference cannot be used as a scalar".into(),
17794            ))
17795        }
17796        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
17797            "structured procedure value cannot be used as a scalar cell".into(),
17798        )),
17799    }
17800}
17801
17802fn eval_return_output(
17803    value: &ProcedureValue,
17804    args: &HashMap<String, crate::Value>,
17805    outputs: &HashMap<String, ProcedureCallOutput>,
17806) -> Result<ProcedureCallOutput> {
17807    match value {
17808        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
17809        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
17810            args.get(name).cloned().ok_or_else(|| {
17811                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17812            })?,
17813        )),
17814        ProcedureValue::StepRows(id)
17815        | ProcedureValue::StepRow(id)
17816        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
17817            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
17818        }),
17819        ProcedureValue::Object(fields) => {
17820            let mut out = Vec::with_capacity(fields.len());
17821            for (name, value) in fields {
17822                out.push((name.clone(), eval_return_output(value, args, outputs)?));
17823            }
17824            Ok(ProcedureCallOutput::Object(out))
17825        }
17826        ProcedureValue::Array(values) => {
17827            let mut out = Vec::with_capacity(values.len());
17828            for value in values {
17829                out.push(eval_return_output(value, args, outputs)?);
17830            }
17831            Ok(ProcedureCallOutput::Array(out))
17832        }
17833    }
17834}
17835
17836fn expect_i64(value: crate::Value) -> Result<i64> {
17837    match value {
17838        crate::Value::Int64(value) => Ok(value),
17839        _ => Err(MongrelError::InvalidArgument(
17840            "procedure value must be Int64".into(),
17841        )),
17842    }
17843}
17844
17845fn expect_f64(value: crate::Value) -> Result<f64> {
17846    match value {
17847        crate::Value::Float64(value) => Ok(value),
17848        _ => Err(MongrelError::InvalidArgument(
17849            "procedure value must be Float64".into(),
17850        )),
17851    }
17852}
17853
17854fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
17855    match value {
17856        crate::Value::Bytes(value) => Ok(value),
17857        _ => Err(MongrelError::InvalidArgument(
17858            "procedure value must be Bytes".into(),
17859        )),
17860    }
17861}
17862
17863fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
17864    if schema.columns.iter().any(|c| c.id == column_id) {
17865        Ok(())
17866    } else {
17867        Err(MongrelError::InvalidArgument(format!(
17868            "unknown column id {column_id}"
17869        )))
17870    }
17871}
17872
17873fn trigger_matches_event(
17874    trigger: &StoredTrigger,
17875    event: &WriteEvent,
17876    cat: &Catalog,
17877) -> Result<bool> {
17878    if trigger.event != event.kind {
17879        return Ok(false);
17880    }
17881    let TriggerTarget::Table(target) = &trigger.target else {
17882        return Ok(false);
17883    };
17884    if target != &event.table {
17885        return Ok(false);
17886    }
17887    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
17888        let schema = &cat
17889            .live(target)
17890            .ok_or_else(|| {
17891                MongrelError::InvalidArgument(format!(
17892                    "trigger {:?} references unknown table {target:?}",
17893                    trigger.name
17894                ))
17895            })?
17896            .schema;
17897        let mut watched = Vec::with_capacity(trigger.update_of.len());
17898        for name in &trigger.update_of {
17899            let col = schema.column(name).ok_or_else(|| {
17900                MongrelError::InvalidArgument(format!(
17901                    "trigger {:?} references unknown UPDATE OF column {name:?}",
17902                    trigger.name
17903                ))
17904            })?;
17905            watched.push(col.id);
17906        }
17907        if !event
17908            .changed_columns
17909            .iter()
17910            .any(|column_id| watched.contains(column_id))
17911        {
17912            return Ok(false);
17913        }
17914    }
17915    Ok(true)
17916}
17917
17918fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
17919    let mut ids = std::collections::BTreeSet::new();
17920    if let Some(old) = old {
17921        ids.extend(old.columns.keys().copied());
17922    }
17923    if let Some(new) = new {
17924        ids.extend(new.columns.keys().copied());
17925    }
17926    ids.into_iter()
17927        .filter(|id| {
17928            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
17929        })
17930        .collect()
17931}
17932
17933fn eval_trigger_cells(
17934    cells: &[crate::trigger::TriggerCell],
17935    event: &WriteEvent,
17936    selected: Option<&TriggerRowImage>,
17937) -> Result<Vec<(u16, Value)>> {
17938    cells
17939        .iter()
17940        .map(|cell| {
17941            Ok((
17942                cell.column_id,
17943                eval_trigger_value(&cell.value, event, selected)?,
17944            ))
17945        })
17946        .collect()
17947}
17948
17949fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
17950    match expr {
17951        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
17952            Value::Bool(value) => Ok(value),
17953            Value::Null => Ok(false),
17954            other => Err(MongrelError::InvalidArgument(format!(
17955                "trigger WHEN value must be boolean, got {other:?}"
17956            ))),
17957        },
17958        TriggerExpr::Eq { left, right } => Ok(values_equal(
17959            &eval_trigger_value(left, event, None)?,
17960            &eval_trigger_value(right, event, None)?,
17961        )),
17962        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
17963            &eval_trigger_value(left, event, None)?,
17964            &eval_trigger_value(right, event, None)?,
17965        )),
17966        TriggerExpr::Lt { left, right } => match value_order(
17967            &eval_trigger_value(left, event, None)?,
17968            &eval_trigger_value(right, event, None)?,
17969        ) {
17970            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17971            None => Ok(false),
17972        },
17973        TriggerExpr::Lte { left, right } => match value_order(
17974            &eval_trigger_value(left, event, None)?,
17975            &eval_trigger_value(right, event, None)?,
17976        ) {
17977            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17978            None => Ok(false),
17979        },
17980        TriggerExpr::Gt { left, right } => match value_order(
17981            &eval_trigger_value(left, event, None)?,
17982            &eval_trigger_value(right, event, None)?,
17983        ) {
17984            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17985            None => Ok(false),
17986        },
17987        TriggerExpr::Gte { left, right } => match value_order(
17988            &eval_trigger_value(left, event, None)?,
17989            &eval_trigger_value(right, event, None)?,
17990        ) {
17991            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
17992            None => Ok(false),
17993        },
17994        TriggerExpr::IsNull(value) => Ok(matches!(
17995            eval_trigger_value(value, event, None)?,
17996            Value::Null
17997        )),
17998        TriggerExpr::IsNotNull(value) => Ok(!matches!(
17999            eval_trigger_value(value, event, None)?,
18000            Value::Null
18001        )),
18002        TriggerExpr::And { left, right } => {
18003            if !eval_trigger_expr(left, event)? {
18004                Ok(false)
18005            } else {
18006                Ok(eval_trigger_expr(right, event)?)
18007            }
18008        }
18009        TriggerExpr::Or { left, right } => {
18010            if eval_trigger_expr(left, event)? {
18011                Ok(true)
18012            } else {
18013                Ok(eval_trigger_expr(right, event)?)
18014            }
18015        }
18016        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
18017    }
18018}
18019
18020fn eval_trigger_condition(
18021    condition: &TriggerCondition,
18022    event: &WriteEvent,
18023    selected: &TriggerRowImage,
18024    schema: &Schema,
18025) -> Result<bool> {
18026    match condition {
18027        TriggerCondition::Pk { value } => {
18028            let pk = schema.primary_key().ok_or_else(|| {
18029                MongrelError::InvalidArgument(
18030                    "trigger condition Pk references a table without a primary key".into(),
18031                )
18032            })?;
18033            let lhs = eval_trigger_value(value, event, Some(selected))?;
18034            Ok(values_equal(
18035                &lhs,
18036                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
18037            ))
18038        }
18039        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
18040            selected.columns.get(column_id).unwrap_or(&Value::Null),
18041            &eval_trigger_value(value, event, Some(selected))?,
18042        )),
18043        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
18044            selected.columns.get(column_id).unwrap_or(&Value::Null),
18045            &eval_trigger_value(value, event, Some(selected))?,
18046        )),
18047        TriggerCondition::Lt { column_id, value } => match value_order(
18048            selected.columns.get(column_id).unwrap_or(&Value::Null),
18049            &eval_trigger_value(value, event, Some(selected))?,
18050        ) {
18051            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
18052            None => Ok(false),
18053        },
18054        TriggerCondition::Lte { column_id, value } => match value_order(
18055            selected.columns.get(column_id).unwrap_or(&Value::Null),
18056            &eval_trigger_value(value, event, Some(selected))?,
18057        ) {
18058            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
18059            None => Ok(false),
18060        },
18061        TriggerCondition::Gt { column_id, value } => match value_order(
18062            selected.columns.get(column_id).unwrap_or(&Value::Null),
18063            &eval_trigger_value(value, event, Some(selected))?,
18064        ) {
18065            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
18066            None => Ok(false),
18067        },
18068        TriggerCondition::Gte { column_id, value } => match value_order(
18069            selected.columns.get(column_id).unwrap_or(&Value::Null),
18070            &eval_trigger_value(value, event, Some(selected))?,
18071        ) {
18072            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
18073            None => Ok(false),
18074        },
18075        TriggerCondition::IsNull { column_id } => Ok(matches!(
18076            selected.columns.get(column_id),
18077            None | Some(Value::Null)
18078        )),
18079        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
18080            selected.columns.get(column_id),
18081            None | Some(Value::Null)
18082        )),
18083        TriggerCondition::And { left, right } => {
18084            if !eval_trigger_condition(left, event, selected, schema)? {
18085                Ok(false)
18086            } else {
18087                Ok(eval_trigger_condition(right, event, selected, schema)?)
18088            }
18089        }
18090        TriggerCondition::Or { left, right } => {
18091            if eval_trigger_condition(left, event, selected, schema)? {
18092                Ok(true)
18093            } else {
18094                Ok(eval_trigger_condition(right, event, selected, schema)?)
18095            }
18096        }
18097        TriggerCondition::Not(condition) => {
18098            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
18099        }
18100    }
18101}
18102
18103fn eval_trigger_value(
18104    value: &TriggerValue,
18105    event: &WriteEvent,
18106    selected: Option<&TriggerRowImage>,
18107) -> Result<Value> {
18108    match value {
18109        TriggerValue::Literal(value) => Ok(value.clone()),
18110        TriggerValue::NewColumn(column_id) => event
18111            .new
18112            .as_ref()
18113            .and_then(|row| row.columns.get(column_id))
18114            .cloned()
18115            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
18116        TriggerValue::OldColumn(column_id) => event
18117            .old
18118            .as_ref()
18119            .and_then(|row| row.columns.get(column_id))
18120            .cloned()
18121            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
18122        TriggerValue::SelectedColumn(column_id) => selected
18123            .and_then(|row| row.columns.get(column_id))
18124            .cloned()
18125            .ok_or_else(|| {
18126                MongrelError::InvalidArgument("SELECTED column is not available".into())
18127            }),
18128    }
18129}
18130
18131fn values_equal(left: &Value, right: &Value) -> bool {
18132    match (left, right) {
18133        (Value::Null, Value::Null) => true,
18134        (Value::Bool(a), Value::Bool(b)) => a == b,
18135        (Value::Int64(a), Value::Int64(b)) => a == b,
18136        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
18137        (Value::Bytes(a), Value::Bytes(b)) => a == b,
18138        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => {
18139            let a = a.as_embedding().unwrap();
18140            let b = b.as_embedding().unwrap();
18141            a.len() == b.len()
18142                && a.iter()
18143                    .zip(b.iter())
18144                    .all(|(a, b)| a.to_bits() == b.to_bits())
18145        }
18146        _ => false,
18147    }
18148}
18149
18150fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
18151    match (left, right) {
18152        (Value::Null, _) | (_, Value::Null) => None,
18153        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
18154        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
18155        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18156        // This matches the spec but can lose precision for i64 values above 2^53.
18157        (Value::Int64(a), Value::Float64(b)) => {
18158            let af = *a as f64;
18159            Some(af.total_cmp(b))
18160        }
18161        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18162        // This matches the spec but can lose precision for i64 values above 2^53.
18163        (Value::Float64(a), Value::Int64(b)) => {
18164            let bf = *b as f64;
18165            Some(a.total_cmp(&bf))
18166        }
18167        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
18168        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
18169        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => None,
18170        _ => None,
18171    }
18172}
18173
18174fn trigger_message(value: Value) -> String {
18175    match value {
18176        Value::Null => "NULL".into(),
18177        Value::Bool(value) => value.to_string(),
18178        Value::Int64(value) => value.to_string(),
18179        Value::Float64(value) => value.to_string(),
18180        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
18181        Value::Embedding(value) => format!("{value:?}"),
18182        Value::GeneratedEmbedding(value) => format!("{:?}", value.vector),
18183        Value::Decimal(value) => value.to_string(),
18184        Value::Interval {
18185            months,
18186            days,
18187            nanos,
18188        } => format!("{months}m {days}d {nanos}ns"),
18189        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
18190        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
18191    }
18192}
18193
18194fn validate_trigger_step<'a>(
18195    step: &TriggerStep,
18196    cat: &'a Catalog,
18197    target_schema: &Schema,
18198    event: TriggerEvent,
18199    select_schemas: &mut HashMap<String, &'a Schema>,
18200) -> Result<()> {
18201    match step {
18202        TriggerStep::SetNew { cells } => {
18203            if event == TriggerEvent::Delete {
18204                return Err(MongrelError::InvalidArgument(
18205                    "SetNew trigger step is not valid for DELETE triggers".into(),
18206                ));
18207            }
18208            for cell in cells {
18209                validate_column_id(cell.column_id, target_schema)?;
18210                validate_trigger_value(&cell.value, target_schema, event)?;
18211            }
18212        }
18213        TriggerStep::Insert { table, cells } => {
18214            let schema = trigger_write_schema(cat, table, "insert")?;
18215            for cell in cells {
18216                validate_column_id(cell.column_id, schema)?;
18217                validate_trigger_value(&cell.value, target_schema, event)?;
18218            }
18219        }
18220        TriggerStep::UpdateByPk { table, pk, cells } => {
18221            let schema = trigger_write_schema(cat, table, "update")?;
18222            if schema.primary_key().is_none() {
18223                return Err(MongrelError::InvalidArgument(format!(
18224                    "trigger update_by_pk references table {table:?} without a primary key"
18225                )));
18226            }
18227            validate_trigger_value(pk, target_schema, event)?;
18228            for cell in cells {
18229                validate_column_id(cell.column_id, schema)?;
18230                validate_trigger_value(&cell.value, target_schema, event)?;
18231            }
18232        }
18233        TriggerStep::DeleteByPk { table, pk } => {
18234            let schema = trigger_write_schema(cat, table, "delete")?;
18235            if schema.primary_key().is_none() {
18236                return Err(MongrelError::InvalidArgument(format!(
18237                    "trigger delete_by_pk references table {table:?} without a primary key"
18238                )));
18239            }
18240            validate_trigger_value(pk, target_schema, event)?;
18241        }
18242        TriggerStep::Select {
18243            id,
18244            table,
18245            conditions,
18246        } => {
18247            let schema = trigger_read_schema(cat, table)?;
18248            for condition in conditions {
18249                validate_trigger_condition(condition, schema, target_schema, event)?;
18250            }
18251            if select_schemas.contains_key(id) {
18252                return Err(MongrelError::InvalidArgument(format!(
18253                    "duplicate select id {id:?} in trigger program"
18254                )));
18255            }
18256            select_schemas.insert(id.clone(), schema);
18257        }
18258        TriggerStep::Foreach { id, steps } => {
18259            if !select_schemas.contains_key(id) {
18260                return Err(MongrelError::InvalidArgument(format!(
18261                    "foreach references unknown select id {id:?}"
18262                )));
18263            }
18264            let mut inner_select_schemas = select_schemas.clone();
18265            for step in steps {
18266                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
18267            }
18268        }
18269        TriggerStep::DeleteWhere { table, conditions } => {
18270            let schema = trigger_write_schema(cat, table, "delete")?;
18271            for condition in conditions {
18272                validate_trigger_condition(condition, schema, target_schema, event)?;
18273            }
18274        }
18275        TriggerStep::UpdateWhere {
18276            table,
18277            conditions,
18278            cells,
18279        } => {
18280            let schema = trigger_write_schema(cat, table, "update")?;
18281            for condition in conditions {
18282                validate_trigger_condition(condition, schema, target_schema, event)?;
18283            }
18284            for cell in cells {
18285                validate_column_id(cell.column_id, schema)?;
18286                validate_trigger_value(&cell.value, target_schema, event)?;
18287            }
18288        }
18289        TriggerStep::Raise { message, .. } => {
18290            validate_trigger_value(message, target_schema, event)?
18291        }
18292    }
18293    Ok(())
18294}
18295
18296fn trigger_validation_error(error: MongrelError) -> MongrelError {
18297    match error {
18298        MongrelError::TriggerValidation(_) => error,
18299        MongrelError::InvalidArgument(message)
18300        | MongrelError::Conflict(message)
18301        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
18302        error => error,
18303    }
18304}
18305
18306fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
18307    if let Some(entry) = cat.live(table) {
18308        return Ok(&entry.schema);
18309    }
18310    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18311        let allowed = match op {
18312            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
18313            "update" | "delete" => entry.capabilities.writable,
18314            _ => false,
18315        };
18316        if !allowed {
18317            return Err(MongrelError::InvalidArgument(format!(
18318                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
18319                entry.module
18320            )));
18321        }
18322        if !entry.capabilities.transaction_safe {
18323            return Err(MongrelError::InvalidArgument(format!(
18324                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
18325                entry.module
18326            )));
18327        }
18328        return Ok(&entry.declared_schema);
18329    }
18330    Err(MongrelError::InvalidArgument(format!(
18331        "trigger references unknown table {table:?}"
18332    )))
18333}
18334
18335fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
18336    if let Some(entry) = cat.live(table) {
18337        return Ok(&entry.schema);
18338    }
18339    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18340        if entry.capabilities.trigger_safe {
18341            return Ok(&entry.declared_schema);
18342        }
18343        return Err(MongrelError::InvalidArgument(format!(
18344            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
18345            entry.module
18346        )));
18347    }
18348    Err(MongrelError::InvalidArgument(format!(
18349        "trigger references unknown table {table:?}"
18350    )))
18351}
18352
18353fn validate_trigger_condition(
18354    condition: &TriggerCondition,
18355    schema: &Schema,
18356    target_schema: &Schema,
18357    event: TriggerEvent,
18358) -> Result<()> {
18359    match condition {
18360        TriggerCondition::Pk { value } => {
18361            if schema.primary_key().is_none() {
18362                return Err(MongrelError::InvalidArgument(
18363                    "trigger condition Pk references a table without a primary key".into(),
18364                ));
18365            }
18366            validate_trigger_value(value, target_schema, event)
18367        }
18368        TriggerCondition::Eq { column_id, value }
18369        | TriggerCondition::NotEq { column_id, value }
18370        | TriggerCondition::Lt { column_id, value }
18371        | TriggerCondition::Lte { column_id, value }
18372        | TriggerCondition::Gt { column_id, value }
18373        | TriggerCondition::Gte { column_id, value } => {
18374            validate_column_id(*column_id, schema)?;
18375            validate_trigger_value(value, target_schema, event)
18376        }
18377        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
18378            validate_column_id(*column_id, schema)
18379        }
18380        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
18381            validate_trigger_condition(left, schema, target_schema, event)?;
18382            validate_trigger_condition(right, schema, target_schema, event)
18383        }
18384        TriggerCondition::Not(condition) => {
18385            validate_trigger_condition(condition, schema, target_schema, event)
18386        }
18387    }
18388}
18389
18390fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
18391    match expr {
18392        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
18393            validate_trigger_value(value, schema, event)
18394        }
18395        TriggerExpr::Eq { left, right }
18396        | TriggerExpr::NotEq { left, right }
18397        | TriggerExpr::Lt { left, right }
18398        | TriggerExpr::Lte { left, right }
18399        | TriggerExpr::Gt { left, right }
18400        | TriggerExpr::Gte { left, right } => {
18401            validate_trigger_value(left, schema, event)?;
18402            validate_trigger_value(right, schema, event)
18403        }
18404        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
18405            validate_trigger_expr(left, schema, event)?;
18406            validate_trigger_expr(right, schema, event)
18407        }
18408        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
18409    }
18410}
18411
18412fn validate_trigger_value(
18413    value: &TriggerValue,
18414    schema: &Schema,
18415    event: TriggerEvent,
18416) -> Result<()> {
18417    match value {
18418        TriggerValue::Literal(_) => Ok(()),
18419        TriggerValue::NewColumn(id) => {
18420            if event == TriggerEvent::Delete {
18421                return Err(MongrelError::InvalidArgument(
18422                    "DELETE triggers cannot reference NEW".into(),
18423                ));
18424            }
18425            validate_column_id(*id, schema)
18426        }
18427        TriggerValue::OldColumn(id) => {
18428            if event == TriggerEvent::Insert {
18429                return Err(MongrelError::InvalidArgument(
18430                    "INSERT triggers cannot reference OLD".into(),
18431                ));
18432            }
18433            validate_column_id(*id, schema)
18434        }
18435        // SELECTED column references are only meaningful inside a foreach loop.
18436        // Strict loop-scope validation is deferred to runtime; the executor raises
18437        // an error if a selected row is not available.
18438        TriggerValue::SelectedColumn(_) => Ok(()),
18439    }
18440}
18441
18442/// Bound on the retained tail of the per-open commit-timestamp ledger
18443/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
18444/// needs recent epochs, so the map keeps the newest commits and drops the
18445/// rest (a miss falls back to a fresh-begin HLC at the caller).
18446const COMMIT_TS_LEDGER_CAP: usize = 10_000;
18447
18448/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
18449/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
18450/// and the `Op::CommitTimestamp` record written ahead of it carries the
18451/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
18452/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
18453/// micros only (same constraint [`crate::commit_log`] documents for replayed
18454/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
18455fn commit_ts_ledger_from_recovery(
18456    records: &[crate::wal::Record],
18457) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
18458    use crate::wal::Op;
18459    let mut commits = HashMap::new();
18460    let mut timestamps = HashMap::new();
18461    for record in records {
18462        match &record.op {
18463            Op::TxnCommit { epoch, .. } => {
18464                commits.insert(record.txn_id, *epoch);
18465            }
18466            Op::CommitTimestamp { unix_nanos } => {
18467                timestamps.insert(record.txn_id, *unix_nanos);
18468            }
18469            _ => {}
18470        }
18471    }
18472    let mut ledger = std::collections::BTreeMap::new();
18473    for (txn_id, epoch) in commits {
18474        let Some(unix_nanos) = timestamps.get(&txn_id) else {
18475            continue;
18476        };
18477        ledger.insert(
18478            epoch,
18479            mongreldb_types::hlc::HlcTimestamp {
18480                physical_micros: unix_nanos / 1_000,
18481                logical: 0,
18482                node_tiebreaker: 0,
18483            },
18484        );
18485    }
18486    while ledger.len() > COMMIT_TS_LEDGER_CAP {
18487        ledger.pop_first();
18488    }
18489    ledger
18490}
18491
18492/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
18493/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
18494/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
18495/// catalog. This pass closes that window by reconstructing missing entries
18496/// (and marking committed drops) before tables are mounted.
18497fn recover_ddl_from_wal(
18498    root: &Path,
18499    durable_root: Option<&crate::durable_file::DurableRoot>,
18500    target_catalog: &mut Catalog,
18501    meta_dek: Option<&[u8; META_DEK_LEN]>,
18502    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
18503    apply: bool,
18504    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18505) -> Result<()> {
18506    use crate::wal::SharedWal;
18507    let records = match durable_root {
18508        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
18509        None => SharedWal::replay_with_dek(root, wal_dek)?,
18510    };
18511    recover_ddl_from_records(
18512        root,
18513        durable_root,
18514        target_catalog,
18515        meta_dek,
18516        apply,
18517        table_roots,
18518        &records,
18519    )
18520}
18521
18522fn recover_ddl_from_records(
18523    root: &Path,
18524    durable_root: Option<&crate::durable_file::DurableRoot>,
18525    target_catalog: &mut Catalog,
18526    meta_dek: Option<&[u8; META_DEK_LEN]>,
18527    apply: bool,
18528    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18529    records: &[crate::wal::Record],
18530) -> Result<()> {
18531    use crate::wal::{DdlOp, Op};
18532
18533    let original_catalog = target_catalog.clone();
18534    let mut recovered_catalog = original_catalog.clone();
18535    let cat = &mut recovered_catalog;
18536    let mut created_table_ids = HashSet::<u64>::new();
18537    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
18538
18539    let mut committed: HashMap<u64, u64> = HashMap::new();
18540    for r in records {
18541        if let Op::TxnCommit { epoch: ce, .. } = r.op {
18542            committed.insert(r.txn_id, ce);
18543        }
18544    }
18545    let catalog_snapshot_txns = records
18546        .iter()
18547        .filter_map(|record| {
18548            (committed.contains_key(&record.txn_id)
18549                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
18550            .then_some(record.txn_id)
18551        })
18552        .collect::<HashSet<_>>();
18553
18554    let mut changed = false;
18555    let mut applied_catalog_epoch = cat.db_epoch;
18556    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
18557    for r in records.iter().cloned() {
18558        let Some(&ce) = committed.get(&r.txn_id) else {
18559            continue;
18560        };
18561        let txn_id = r.txn_id;
18562        match r.op {
18563            Op::Ddl(DdlOp::CreateTable {
18564                table_id,
18565                ref name,
18566                ref schema_json,
18567            }) => {
18568                if cat.tables.iter().any(|t| t.table_id == table_id) {
18569                    continue;
18570                }
18571                let schema = DdlOp::decode_schema(schema_json)?;
18572                validate_recovered_schema(&schema)?;
18573                created_table_ids.insert(table_id);
18574                cat.tables.push(CatalogEntry {
18575                    table_id,
18576                    name: name.clone(),
18577                    schema,
18578                    state: TableState::Live,
18579                    created_epoch: ce,
18580                });
18581                cat.next_table_id =
18582                    cat.next_table_id
18583                        .max(table_id.checked_add(1).ok_or_else(|| {
18584                            MongrelError::Full("table id namespace exhausted".into())
18585                        })?);
18586                changed = true;
18587            }
18588            Op::Ddl(DdlOp::CreateBuildingTable {
18589                table_id,
18590                ref build_name,
18591                ref intended_name,
18592                ref query_id,
18593                created_at_unix_nanos,
18594                ref schema_json,
18595            }) => {
18596                if cat.tables.iter().any(|table| table.table_id == table_id) {
18597                    continue;
18598                }
18599                let schema = DdlOp::decode_schema(schema_json)?;
18600                validate_recovered_schema(&schema)?;
18601                created_table_ids.insert(table_id);
18602                cat.tables.push(CatalogEntry {
18603                    table_id,
18604                    name: build_name.clone(),
18605                    schema,
18606                    state: TableState::Building {
18607                        intended_name: intended_name.clone(),
18608                        query_id: query_id.clone(),
18609                        created_at_unix_nanos,
18610                        replaces_table_id: None,
18611                    },
18612                    created_epoch: ce,
18613                });
18614                cat.next_table_id =
18615                    cat.next_table_id
18616                        .max(table_id.checked_add(1).ok_or_else(|| {
18617                            MongrelError::Full("table id namespace exhausted".into())
18618                        })?);
18619                changed = true;
18620            }
18621            Op::Ddl(DdlOp::CreateRebuildingTable {
18622                table_id,
18623                ref build_name,
18624                ref intended_name,
18625                ref query_id,
18626                created_at_unix_nanos,
18627                replaces_table_id,
18628                ref schema_json,
18629            }) => {
18630                if cat.tables.iter().any(|table| table.table_id == table_id) {
18631                    continue;
18632                }
18633                let schema = DdlOp::decode_schema(schema_json)?;
18634                validate_recovered_schema(&schema)?;
18635                created_table_ids.insert(table_id);
18636                cat.tables.push(CatalogEntry {
18637                    table_id,
18638                    name: build_name.clone(),
18639                    schema,
18640                    state: TableState::Building {
18641                        intended_name: intended_name.clone(),
18642                        query_id: query_id.clone(),
18643                        created_at_unix_nanos,
18644                        replaces_table_id: Some(replaces_table_id),
18645                    },
18646                    created_epoch: ce,
18647                });
18648                cat.next_table_id =
18649                    cat.next_table_id
18650                        .max(table_id.checked_add(1).ok_or_else(|| {
18651                            MongrelError::Full("table id namespace exhausted".into())
18652                        })?);
18653                changed = true;
18654            }
18655            Op::Ddl(DdlOp::DropTable { table_id }) => {
18656                let mut dropped_name = None;
18657                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18658                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18659                        dropped_name = Some(entry.name.clone());
18660                        entry.state = TableState::Dropped { at_epoch: ce };
18661                        changed = true;
18662                    }
18663                }
18664                if let Some(name) = dropped_name {
18665                    let before = cat.materialized_views.len();
18666                    cat.materialized_views
18667                        .retain(|definition| definition.name != name);
18668                    changed |= before != cat.materialized_views.len();
18669                    cat.security.rls_tables.retain(|table| table != &name);
18670                    cat.security.policies.retain(|policy| policy.table != name);
18671                    cat.security.masks.retain(|mask| mask.table != name);
18672                    for role in &mut cat.roles {
18673                        role.permissions
18674                            .retain(|permission| permission_table(permission) != Some(&name));
18675                    }
18676                    if !catalog_snapshot_txns.contains(&txn_id) {
18677                        advance_security_version(cat)?;
18678                    }
18679                }
18680            }
18681            Op::Ddl(DdlOp::PublishBuildingTable {
18682                table_id,
18683                ref new_name,
18684            }) => {
18685                if let Some(entry) = cat
18686                    .tables
18687                    .iter_mut()
18688                    .find(|table| table.table_id == table_id)
18689                {
18690                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
18691                        entry.name = new_name.clone();
18692                        entry.state = TableState::Live;
18693                        changed = true;
18694                    }
18695                }
18696            }
18697            Op::Ddl(DdlOp::ReplaceBuildingTable {
18698                table_id,
18699                replaced_table_id,
18700                ref new_name,
18701            }) => {
18702                changed |=
18703                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
18704            }
18705            Op::Ddl(DdlOp::RenameTable {
18706                table_id,
18707                ref new_name,
18708            }) => {
18709                let mut old_name = None;
18710                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18711                    if entry.name != *new_name {
18712                        old_name = Some(entry.name.clone());
18713                        entry.name = new_name.clone();
18714                        changed = true;
18715                    }
18716                }
18717                if let Some(old_name) = old_name {
18718                    if let Some(definition) = cat
18719                        .materialized_views
18720                        .iter_mut()
18721                        .find(|definition| definition.name == old_name)
18722                    {
18723                        definition.name = new_name.clone();
18724                    }
18725                    for table in &mut cat.security.rls_tables {
18726                        if *table == old_name {
18727                            *table = new_name.clone();
18728                        }
18729                    }
18730                    for policy in &mut cat.security.policies {
18731                        if policy.table == old_name {
18732                            policy.table = new_name.clone();
18733                        }
18734                    }
18735                    for mask in &mut cat.security.masks {
18736                        if mask.table == old_name {
18737                            mask.table = new_name.clone();
18738                        }
18739                    }
18740                    for role in &mut cat.roles {
18741                        for permission in &mut role.permissions {
18742                            rename_permission_table(permission, &old_name, new_name);
18743                        }
18744                    }
18745                    if !catalog_snapshot_txns.contains(&txn_id) {
18746                        advance_security_version(cat)?;
18747                    }
18748                }
18749                // If the entry is absent, its CreateTable was already
18750                // checkpointed carrying the post-rename name, so there is
18751                // nothing to apply — a no-op, not an error.
18752            }
18753            Op::Ddl(DdlOp::AlterTable {
18754                table_id,
18755                ref column_json,
18756            }) => {
18757                let column = DdlOp::decode_column(column_json)?;
18758                let mut renamed = None;
18759                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18760                    renamed = entry
18761                        .schema
18762                        .columns
18763                        .iter()
18764                        .find(|existing| existing.id == column.id && existing.name != column.name)
18765                        .map(|existing| {
18766                            (
18767                                entry.name.clone(),
18768                                existing.name.clone(),
18769                                column.name.clone(),
18770                            )
18771                        });
18772                    if apply_recovered_column_def(&mut entry.schema, column)? {
18773                        validate_recovered_schema(&entry.schema)?;
18774                        changed = true;
18775                    }
18776                }
18777                if let Some((table, old_name, new_name)) = renamed {
18778                    for role in &mut cat.roles {
18779                        for permission in &mut role.permissions {
18780                            rename_permission_column(permission, &table, &old_name, &new_name);
18781                        }
18782                    }
18783                    if !catalog_snapshot_txns.contains(&txn_id) {
18784                        advance_security_version(cat)?;
18785                    }
18786                }
18787            }
18788            Op::Ddl(DdlOp::SetTtl {
18789                table_id,
18790                ref policy_json,
18791            }) => {
18792                let policy = DdlOp::decode_ttl(policy_json)?;
18793                let entry = cat
18794                    .tables
18795                    .iter()
18796                    .find(|entry| entry.table_id == table_id)
18797                    .ok_or_else(|| {
18798                        MongrelError::Schema(format!(
18799                            "recovered TTL references unknown table id {table_id}"
18800                        ))
18801                    })?;
18802                if let Some(policy) = policy {
18803                    let valid = entry
18804                        .schema
18805                        .columns
18806                        .iter()
18807                        .find(|column| column.id == policy.column_id)
18808                        .is_some_and(|column| {
18809                            column.ty == TypeId::TimestampNanos
18810                                && policy.duration_nanos > 0
18811                                && policy.duration_nanos <= i64::MAX as u64
18812                        });
18813                    if !valid {
18814                        return Err(MongrelError::Schema(format!(
18815                            "invalid recovered TTL policy for table id {table_id}"
18816                        )));
18817                    }
18818                }
18819                ttl_updates.insert(table_id, (policy, ce));
18820            }
18821            Op::Ddl(DdlOp::SetMaterializedView {
18822                ref name,
18823                ref definition_json,
18824            }) => {
18825                let definition = DdlOp::decode_materialized_view(definition_json)?;
18826                if definition.name != *name {
18827                    return Err(MongrelError::Schema(format!(
18828                        "materialized view WAL name mismatch: {name:?}"
18829                    )));
18830                }
18831                if cat.live(name).is_some() {
18832                    if let Some(existing) = cat
18833                        .materialized_views
18834                        .iter_mut()
18835                        .find(|existing| existing.name == *name)
18836                    {
18837                        if *existing != definition {
18838                            *existing = definition;
18839                            changed = true;
18840                        }
18841                    } else {
18842                        cat.materialized_views.push(definition);
18843                        changed = true;
18844                    }
18845                }
18846            }
18847            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
18848                let security = DdlOp::decode_security(security_json)?;
18849                validate_security_catalog(cat, &security)?;
18850                if cat.security != security {
18851                    cat.security = security;
18852                    if !catalog_snapshot_txns.contains(&txn_id) {
18853                        advance_security_version(cat)?;
18854                    }
18855                    changed = true;
18856                }
18857            }
18858            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
18859                let target = match key.as_str() {
18860                    "user_version" => &mut cat.user_version,
18861                    "application_id" => &mut cat.application_id,
18862                    _ => {
18863                        return Err(MongrelError::InvalidArgument(format!(
18864                            "unsupported recovered SQL pragma {key:?}"
18865                        )))
18866                    }
18867                };
18868                if *target != Some(value) {
18869                    *target = Some(value);
18870                    cat.db_epoch = cat.db_epoch.max(ce);
18871                    changed = true;
18872                }
18873            }
18874            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
18875                if ce <= applied_catalog_epoch {
18876                    continue;
18877                }
18878                let snapshot = DdlOp::decode_catalog(catalog_json)?;
18879                if snapshot.db_epoch != ce {
18880                    return Err(MongrelError::Schema(format!(
18881                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
18882                        snapshot.db_epoch
18883                    )));
18884                }
18885                validate_recovered_catalog(&snapshot)?;
18886                validate_catalog_transition(cat, &snapshot)?;
18887                *cat = snapshot;
18888                applied_catalog_epoch = ce;
18889                changed = true;
18890            }
18891            _ => {}
18892        }
18893    }
18894
18895    if cat.db_epoch < max_committed_epoch {
18896        cat.db_epoch = max_committed_epoch;
18897        changed = true;
18898    }
18899    changed |= repair_catalog_allocator_counters(cat)?;
18900
18901    validate_recovered_catalog(cat)?;
18902    let storage_reconciliation = validate_recovered_storage_plan(
18903        root,
18904        durable_root,
18905        cat,
18906        &created_table_ids,
18907        &ttl_updates,
18908        meta_dek,
18909    )?;
18910
18911    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
18912    if apply && (changed || needs_storage_apply) {
18913        for table_id in storage_reconciliation {
18914            let entry = cat
18915                .tables
18916                .iter()
18917                .find(|entry| entry.table_id == table_id)
18918                .ok_or_else(|| MongrelError::CorruptWal {
18919                    offset: table_id,
18920                    reason: "recovery storage plan lost its catalog table".into(),
18921                })?;
18922            ensure_recovered_table_storage(
18923                table_roots
18924                    .and_then(|roots| roots.get(&table_id))
18925                    .map(Arc::as_ref),
18926                durable_root,
18927                &root.join(TABLES_DIR).join(table_id.to_string()),
18928                table_id,
18929                &entry.schema,
18930                meta_dek,
18931            )?;
18932        }
18933        for (table_id, (policy, ttl_epoch)) in ttl_updates {
18934            let Some(entry) = cat.tables.iter().find(|entry| {
18935                entry.table_id == table_id
18936                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
18937            }) else {
18938                continue;
18939            };
18940            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
18941            {
18942                root.try_clone()?
18943            } else if let Some(root) = durable_root {
18944                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
18945            } else {
18946                crate::durable_file::DurableRoot::open(
18947                    root.join(TABLES_DIR).join(table_id.to_string()),
18948                )?
18949            };
18950            let table_dir = table_root.io_path()?;
18951            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
18952            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
18953                manifest.ttl = policy;
18954                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
18955                manifest.schema_id = entry.schema.schema_id;
18956                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18957            }
18958        }
18959        if changed {
18960            match durable_root {
18961                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
18962                None => catalog::write_atomic(root, cat, meta_dek)?,
18963            }
18964        }
18965    }
18966    *target_catalog = recovered_catalog;
18967    Ok(())
18968}
18969
18970fn ensure_recovered_table_storage(
18971    pinned_table: Option<&crate::durable_file::DurableRoot>,
18972    durable_root: Option<&crate::durable_file::DurableRoot>,
18973    fallback_table_dir: &Path,
18974    table_id: u64,
18975    schema: &Schema,
18976    meta_dek: Option<&[u8; META_DEK_LEN]>,
18977) -> Result<()> {
18978    let table_root = if let Some(root) = pinned_table {
18979        root.try_clone()?
18980    } else if let Some(root) = durable_root {
18981        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
18982        match root.open_directory(&relative) {
18983            Ok(table) => table,
18984            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
18985                root.create_directory_all_pinned(relative)?
18986            }
18987            Err(error) => return Err(error.into()),
18988        }
18989    } else {
18990        crate::durable_file::create_directory_all(fallback_table_dir)?;
18991        crate::durable_file::DurableRoot::open(fallback_table_dir)?
18992    };
18993    let table_dir = table_root.io_path()?;
18994    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
18995        Ok(manifest) => {
18996            if manifest.table_id != table_id {
18997                return Err(MongrelError::Conflict(format!(
18998                    "recovered table directory id mismatch: expected {table_id}, found {}",
18999                    manifest.table_id
19000                )));
19001            }
19002            Some(manifest)
19003        }
19004        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
19005        Err(error) => return Err(error),
19006    };
19007
19008    table_root.create_directory_all(crate::engine::WAL_DIR)?;
19009    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
19010    crate::engine::write_schema(&table_dir, schema)?;
19011
19012    if let Some(mut manifest) = existing_manifest.take() {
19013        if manifest.schema_id != schema.schema_id {
19014            manifest.schema_id = schema.schema_id;
19015            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
19016        }
19017    } else {
19018        // The DB-wide meta DEK is also the per-table manifest meta DEK.
19019        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
19020        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
19021    }
19022    Ok(())
19023}
19024
19025fn validate_recovered_schema(schema: &Schema) -> Result<()> {
19026    schema.validate_auto_increment()?;
19027    schema.validate_defaults()?;
19028    schema.validate_ai()?;
19029    let mut column_ids = HashSet::new();
19030    let mut column_names = HashSet::new();
19031    for column in &schema.columns {
19032        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
19033            return Err(MongrelError::Schema(
19034                "recovered schema contains duplicate columns".into(),
19035            ));
19036        }
19037        match &column.ty {
19038            TypeId::Decimal128 { precision, scale }
19039                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
19040            {
19041                return Err(MongrelError::Schema(format!(
19042                    "column {:?} has invalid decimal precision or scale",
19043                    column.name
19044                )));
19045            }
19046            TypeId::Enum { variants }
19047                if variants.is_empty()
19048                    || variants.iter().any(String::is_empty)
19049                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
19050            {
19051                return Err(MongrelError::Schema(format!(
19052                    "column {:?} has invalid enum variants",
19053                    column.name
19054                )));
19055            }
19056            _ => {}
19057        }
19058    }
19059    let mut index_names = HashSet::new();
19060    for index in &schema.indexes {
19061        index.validate_options()?;
19062        if index.name.is_empty()
19063            || !index_names.insert(index.name.as_str())
19064            || schema
19065                .columns
19066                .iter()
19067                .all(|column| column.id != index.column_id)
19068        {
19069            return Err(MongrelError::Schema(format!(
19070                "recovered index {:?} references missing column {}",
19071                index.name, index.column_id
19072            )));
19073        }
19074    }
19075    let mut colocated = HashSet::new();
19076    for group in &schema.colocation {
19077        if group.is_empty()
19078            || group.iter().any(|id| !column_ids.contains(id))
19079            || group.iter().any(|id| !colocated.insert(*id))
19080        {
19081            return Err(MongrelError::Schema(
19082                "recovered schema contains invalid column co-location groups".into(),
19083            ));
19084        }
19085    }
19086
19087    let mut constraint_ids = HashSet::new();
19088    let mut constraint_names = HashSet::<String>::new();
19089    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
19090        if name.is_empty()
19091            || !constraint_ids.insert(id)
19092            || !constraint_names.insert(name.to_owned())
19093        {
19094            return Err(MongrelError::Schema(
19095                "recovered schema contains duplicate or empty constraint identities".into(),
19096            ));
19097        }
19098        Ok(())
19099    };
19100    for unique in &schema.constraints.uniques {
19101        validate_constraint_identity(unique.id, &unique.name)?;
19102        if unique.columns.is_empty()
19103            || unique.columns.iter().any(|id| !column_ids.contains(id))
19104            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
19105        {
19106            return Err(MongrelError::Schema(format!(
19107                "unique constraint {:?} has invalid columns",
19108                unique.name
19109            )));
19110        }
19111    }
19112    for foreign_key in &schema.constraints.foreign_keys {
19113        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
19114        if foreign_key.ref_table.is_empty()
19115            || foreign_key.columns.is_empty()
19116            || foreign_key.columns.len() != foreign_key.ref_columns.len()
19117            || foreign_key
19118                .columns
19119                .iter()
19120                .any(|id| !column_ids.contains(id))
19121            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
19122            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
19123                != foreign_key.ref_columns.len()
19124        {
19125            return Err(MongrelError::Schema(format!(
19126                "foreign key {:?} has invalid columns",
19127                foreign_key.name
19128            )));
19129        }
19130        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
19131            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
19132            && foreign_key.columns.iter().any(|id| {
19133                schema
19134                    .columns
19135                    .iter()
19136                    .find(|column| column.id == *id)
19137                    .is_none_or(|column| {
19138                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
19139                    })
19140            })
19141        {
19142            return Err(MongrelError::Schema(format!(
19143                "foreign key {:?} uses SET NULL on a non-nullable column",
19144                foreign_key.name
19145            )));
19146        }
19147    }
19148    for check in &schema.constraints.checks {
19149        validate_constraint_identity(check.id, &check.name)?;
19150        check.expr.validate()?;
19151        validate_check_columns(&check.expr, &column_ids)?;
19152    }
19153    Ok(())
19154}
19155
19156fn validate_check_columns(
19157    expression: &crate::constraint::CheckExpr,
19158    column_ids: &HashSet<u16>,
19159) -> Result<()> {
19160    use crate::constraint::CheckExpr;
19161    match expression {
19162        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
19163            if column_ids.contains(id) {
19164                Ok(())
19165            } else {
19166                Err(MongrelError::Schema(format!(
19167                    "check constraint references unknown column {id}"
19168                )))
19169            }
19170        }
19171        CheckExpr::Regex { col, .. } => {
19172            if column_ids.contains(col) {
19173                Ok(())
19174            } else {
19175                Err(MongrelError::Schema(format!(
19176                    "check constraint references unknown column {col}"
19177                )))
19178            }
19179        }
19180        CheckExpr::Add(left, right)
19181        | CheckExpr::Sub(left, right)
19182        | CheckExpr::Mul(left, right)
19183        | CheckExpr::Div(left, right)
19184        | CheckExpr::Mod(left, right)
19185        | CheckExpr::Eq(left, right)
19186        | CheckExpr::Ne(left, right)
19187        | CheckExpr::Lt(left, right)
19188        | CheckExpr::Le(left, right)
19189        | CheckExpr::Gt(left, right)
19190        | CheckExpr::Ge(left, right)
19191        | CheckExpr::And(left, right)
19192        | CheckExpr::Or(left, right) => {
19193            validate_check_columns(left, column_ids)?;
19194            validate_check_columns(right, column_ids)
19195        }
19196        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
19197        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
19198    }
19199}
19200
19201fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
19202    for (name, prior, candidate) in [
19203        ("db_epoch", current.db_epoch, next.db_epoch),
19204        ("next_table_id", current.next_table_id, next.next_table_id),
19205        (
19206            "next_segment_no",
19207            current.next_segment_no,
19208            next.next_segment_no,
19209        ),
19210        ("next_user_id", current.next_user_id, next.next_user_id),
19211        (
19212            "security_version",
19213            current.security_version,
19214            next.security_version,
19215        ),
19216    ] {
19217        if candidate < prior {
19218            return Err(MongrelError::Schema(format!(
19219                "catalog snapshot rolls back {name} from {prior} to {candidate}"
19220            )));
19221        }
19222    }
19223    for prior in &current.tables {
19224        let Some(candidate) = next
19225            .tables
19226            .iter()
19227            .find(|entry| entry.table_id == prior.table_id)
19228        else {
19229            return Err(MongrelError::Schema(format!(
19230                "catalog snapshot removes table identity {}",
19231                prior.table_id
19232            )));
19233        };
19234        if candidate.created_epoch != prior.created_epoch
19235            || candidate.schema.schema_id < prior.schema.schema_id
19236            || matches!(prior.state, TableState::Dropped { .. })
19237                && !matches!(candidate.state, TableState::Dropped { .. })
19238        {
19239            return Err(MongrelError::Schema(format!(
19240                "catalog snapshot rolls back table identity {}",
19241                prior.table_id
19242            )));
19243        }
19244    }
19245    for prior in &current.users {
19246        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
19247            if candidate.username != prior.username
19248                || candidate.created_epoch != prior.created_epoch
19249            {
19250                return Err(MongrelError::Schema(format!(
19251                    "catalog snapshot reuses user identity {}",
19252                    prior.id
19253                )));
19254            }
19255        }
19256    }
19257    Ok(())
19258}
19259
19260fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
19261    let mut table_ids = HashSet::new();
19262    let mut active_names = HashSet::new();
19263    let mut max_table_id = None::<u64>;
19264    for entry in &catalog.tables {
19265        if !table_ids.insert(entry.table_id) {
19266            return Err(MongrelError::Schema(format!(
19267                "catalog contains duplicate table id {}",
19268                entry.table_id
19269            )));
19270        }
19271        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
19272        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
19273            return Err(MongrelError::Schema(format!(
19274                "catalog table {} has invalid name or creation epoch",
19275                entry.table_id
19276            )));
19277        }
19278        validate_recovered_schema(&entry.schema)?;
19279        match &entry.state {
19280            TableState::Live => {
19281                if !active_names.insert(entry.name.as_str()) {
19282                    return Err(MongrelError::Schema(format!(
19283                        "catalog contains duplicate active table name {:?}",
19284                        entry.name
19285                    )));
19286                }
19287            }
19288            TableState::Dropped { at_epoch } => {
19289                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
19290                    return Err(MongrelError::Schema(format!(
19291                        "catalog table {} has invalid drop epoch {at_epoch}",
19292                        entry.table_id
19293                    )));
19294                }
19295            }
19296            TableState::Building {
19297                intended_name,
19298                query_id,
19299                replaces_table_id,
19300                ..
19301            } => {
19302                if intended_name.is_empty() || query_id.is_empty() {
19303                    return Err(MongrelError::Schema(format!(
19304                        "building table {} has empty identity fields",
19305                        entry.table_id
19306                    )));
19307                }
19308                if !active_names.insert(entry.name.as_str()) {
19309                    return Err(MongrelError::Schema(format!(
19310                        "catalog contains duplicate active/building table name {:?}",
19311                        entry.name
19312                    )));
19313                }
19314                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
19315                    return Err(MongrelError::Schema(
19316                        "building table cannot replace itself".into(),
19317                    ));
19318                }
19319            }
19320        }
19321    }
19322    if let Some(maximum) = max_table_id {
19323        let required = maximum
19324            .checked_add(1)
19325            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19326        if catalog.next_table_id < required {
19327            return Err(MongrelError::Schema(format!(
19328                "catalog next_table_id {} precedes required {required}",
19329                catalog.next_table_id
19330            )));
19331        }
19332    }
19333    for entry in &catalog.tables {
19334        if let TableState::Building {
19335            replaces_table_id: Some(replaced),
19336            ..
19337        } = entry.state
19338        {
19339            if !table_ids.contains(&replaced) {
19340                return Err(MongrelError::Schema(format!(
19341                    "building table {} replaces unknown table {replaced}",
19342                    entry.table_id
19343                )));
19344            }
19345        }
19346    }
19347    for entry in &catalog.tables {
19348        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19349            validate_foreign_key_targets(catalog, &entry.schema)?;
19350        }
19351    }
19352
19353    let mut external_names = HashSet::new();
19354    for entry in &catalog.external_tables {
19355        entry.validate()?;
19356        validate_recovered_schema(&entry.declared_schema)?;
19357        if !entry.declared_schema.constraints.is_empty() {
19358            return Err(MongrelError::Schema(format!(
19359                "external table {:?} cannot carry engine-enforced constraints",
19360                entry.name
19361            )));
19362        }
19363        if entry.created_epoch > catalog.db_epoch
19364            || !external_names.insert(entry.name.as_str())
19365            || active_names.contains(entry.name.as_str())
19366        {
19367            return Err(MongrelError::Schema(format!(
19368                "invalid or duplicate external table {:?}",
19369                entry.name
19370            )));
19371        }
19372    }
19373
19374    let mut procedure_names = HashSet::new();
19375    for entry in &catalog.procedures {
19376        entry.procedure.validate()?;
19377        if entry.procedure.created_epoch > entry.procedure.updated_epoch
19378            || entry.procedure.updated_epoch > catalog.db_epoch
19379            || !procedure_names.insert(entry.procedure.name.as_str())
19380        {
19381            return Err(MongrelError::Schema(format!(
19382                "invalid or duplicate procedure {:?}",
19383                entry.procedure.name
19384            )));
19385        }
19386        validate_recovered_procedure_references(catalog, &entry.procedure)?;
19387    }
19388
19389    let mut trigger_names = HashSet::new();
19390    for entry in &catalog.triggers {
19391        entry.trigger.validate()?;
19392        if entry.trigger.created_epoch > entry.trigger.updated_epoch
19393            || entry.trigger.updated_epoch > catalog.db_epoch
19394            || !trigger_names.insert(entry.trigger.name.as_str())
19395        {
19396            return Err(MongrelError::Schema(format!(
19397                "invalid or duplicate trigger {:?}",
19398                entry.trigger.name
19399            )));
19400        }
19401        validate_recovered_trigger_references(catalog, &entry.trigger)?;
19402    }
19403
19404    let mut views = HashSet::new();
19405    for view in &catalog.materialized_views {
19406        let target = catalog.live(&view.name).ok_or_else(|| {
19407            MongrelError::Schema(format!(
19408                "materialized view {:?} has no live table",
19409                view.name
19410            ))
19411        })?;
19412        if view.name.is_empty()
19413            || view.query.trim().is_empty()
19414            || view.last_refresh_epoch > catalog.db_epoch
19415            || !views.insert(view.name.as_str())
19416        {
19417            return Err(MongrelError::Schema(format!(
19418                "materialized view {:?} has no unique live table",
19419                view.name
19420            )));
19421        }
19422        if let Some(incremental) = &view.incremental {
19423            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
19424                MongrelError::Schema(format!(
19425                    "materialized view {:?} references missing source {:?}",
19426                    view.name, incremental.source_table
19427                ))
19428            })?;
19429            if source.table_id != incremental.source_table_id
19430                || source
19431                    .schema
19432                    .columns
19433                    .iter()
19434                    .all(|column| column.id != incremental.group_column)
19435            {
19436                return Err(MongrelError::Schema(format!(
19437                    "materialized view {:?} has invalid incremental source",
19438                    view.name
19439                )));
19440            }
19441            let target_ids = target
19442                .schema
19443                .columns
19444                .iter()
19445                .map(|column| column.id)
19446                .collect::<HashSet<_>>();
19447            let mut output_ids = HashSet::new();
19448            let count_outputs = incremental
19449                .outputs
19450                .iter()
19451                .filter(|output| {
19452                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19453                })
19454                .count();
19455            if incremental.checkpoint_event_id.is_empty()
19456                || !target_ids.contains(&incremental.group_output_column)
19457                || !target_ids.contains(&incremental.count_output_column)
19458                || incremental.outputs.is_empty()
19459                || count_outputs != 1
19460                || incremental.outputs.iter().any(|output| {
19461                    !target_ids.contains(&output.output_column)
19462                        || output.output_column == incremental.group_output_column
19463                        || !output_ids.insert(output.output_column)
19464                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19465                            && output.output_column != incremental.count_output_column
19466                        || match output.kind {
19467                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
19468                                source
19469                                    .schema
19470                                    .columns
19471                                    .iter()
19472                                    .all(|column| column.id != source_column)
19473                            }
19474                            crate::catalog::IncrementalAggregateKind::Count => false,
19475                        }
19476                })
19477            {
19478                return Err(MongrelError::Schema(format!(
19479                    "materialized view {:?} has invalid incremental outputs",
19480                    view.name
19481                )));
19482            }
19483        }
19484    }
19485
19486    validate_security_catalog(catalog, &catalog.security)?;
19487    validate_recovered_auth_catalog(catalog)?;
19488    Ok(())
19489}
19490
19491fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
19492    let mut changed = false;
19493    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
19494        let required = maximum
19495            .checked_add(1)
19496            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19497        if catalog.next_table_id < required {
19498            catalog.next_table_id = required;
19499            changed = true;
19500        }
19501    }
19502    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
19503        let required = maximum
19504            .checked_add(1)
19505            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
19506        if catalog.next_user_id < required {
19507            catalog.next_user_id = required;
19508            changed = true;
19509        }
19510    }
19511    Ok(changed)
19512}
19513
19514fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
19515    for foreign_key in &schema.constraints.foreign_keys {
19516        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
19517            MongrelError::Schema(format!(
19518                "foreign key {:?} references unknown live table {:?}",
19519                foreign_key.name, foreign_key.ref_table
19520            ))
19521        })?;
19522        let referenced_unique = parent
19523            .schema
19524            .constraints
19525            .uniques
19526            .iter()
19527            .any(|unique| unique.columns == foreign_key.ref_columns)
19528            || foreign_key.ref_columns.len() == 1
19529                && parent
19530                    .schema
19531                    .primary_key()
19532                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
19533        if !referenced_unique {
19534            return Err(MongrelError::Schema(format!(
19535                "foreign key {:?} does not reference a unique key",
19536                foreign_key.name
19537            )));
19538        }
19539        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
19540            let local = schema.columns.iter().find(|column| column.id == *local_id);
19541            let referenced = parent
19542                .schema
19543                .columns
19544                .iter()
19545                .find(|column| column.id == *parent_id);
19546            if local
19547                .zip(referenced)
19548                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
19549            {
19550                return Err(MongrelError::Schema(format!(
19551                    "foreign key {:?} has missing or incompatible columns",
19552                    foreign_key.name
19553                )));
19554            }
19555        }
19556    }
19557    Ok(())
19558}
19559
19560fn validate_recovered_procedure_references(
19561    catalog: &Catalog,
19562    procedure: &StoredProcedure,
19563) -> Result<()> {
19564    for step in &procedure.body.steps {
19565        let Some(table_name) = step.table() else {
19566            continue;
19567        };
19568        let schema = &catalog
19569            .live(table_name)
19570            .ok_or_else(|| {
19571                MongrelError::Schema(format!(
19572                    "procedure {:?} references unknown table {table_name:?}",
19573                    procedure.name
19574                ))
19575            })?
19576            .schema;
19577        match step {
19578            ProcedureStep::NativeQuery {
19579                conditions,
19580                projection,
19581                ..
19582            } => {
19583                for condition in conditions {
19584                    validate_condition_columns(condition, schema)?;
19585                }
19586                for id in projection.iter().flatten() {
19587                    validate_column_id(*id, schema)?;
19588                }
19589            }
19590            ProcedureStep::Put { cells, .. } => {
19591                for cell in cells {
19592                    validate_column_id(cell.column_id, schema)?;
19593                }
19594            }
19595            ProcedureStep::Upsert {
19596                cells,
19597                update_cells,
19598                ..
19599            } => {
19600                for cell in cells.iter().chain(update_cells.iter().flatten()) {
19601                    validate_column_id(cell.column_id, schema)?;
19602                }
19603            }
19604            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
19605                return Err(MongrelError::Schema(format!(
19606                    "procedure {:?} deletes by primary key on table without one",
19607                    procedure.name
19608                )));
19609            }
19610            ProcedureStep::DeleteByPk { .. }
19611            | ProcedureStep::DeleteRows { .. }
19612            | ProcedureStep::SqlQuery { .. } => {}
19613        }
19614    }
19615    Ok(())
19616}
19617
19618fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
19619    let target_schema = match &trigger.target {
19620        TriggerTarget::Table(name) => catalog
19621            .live(name)
19622            .ok_or_else(|| {
19623                MongrelError::Schema(format!(
19624                    "trigger {:?} references unknown table {name:?}",
19625                    trigger.name
19626                ))
19627            })?
19628            .schema
19629            .clone(),
19630        TriggerTarget::View(_) => Schema {
19631            columns: trigger.target_columns.clone(),
19632            ..Schema::default()
19633        },
19634    };
19635    for column in &trigger.update_of {
19636        if target_schema.column(column).is_none() {
19637            return Err(MongrelError::Schema(format!(
19638                "trigger {:?} references unknown UPDATE OF column {column:?}",
19639                trigger.name
19640            )));
19641        }
19642    }
19643    if let Some(expr) = &trigger.when {
19644        validate_trigger_expr(expr, &target_schema, trigger.event)?;
19645    }
19646    let mut selects = HashMap::new();
19647    for step in &trigger.program.steps {
19648        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
19649            return Err(MongrelError::Schema(
19650                "SetNew is only valid in BEFORE triggers".into(),
19651            ));
19652        }
19653        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
19654    }
19655    Ok(())
19656}
19657
19658fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
19659    let mut role_names = HashSet::new();
19660    for role in &catalog.roles {
19661        if role.name.is_empty()
19662            || role.created_epoch > catalog.db_epoch
19663            || !role_names.insert(role.name.as_str())
19664        {
19665            return Err(MongrelError::Schema(format!(
19666                "invalid or duplicate role {:?}",
19667                role.name
19668            )));
19669        }
19670        for permission in &role.permissions {
19671            if let Some(table) = permission_table(permission) {
19672                let schema = catalog
19673                    .live(table)
19674                    .map(|entry| &entry.schema)
19675                    .or_else(|| {
19676                        catalog
19677                            .external_tables
19678                            .iter()
19679                            .find(|entry| entry.name == table)
19680                            .map(|entry| &entry.declared_schema)
19681                    })
19682                    .ok_or_else(|| {
19683                        MongrelError::Schema(format!(
19684                            "role {:?} references unknown table {table:?}",
19685                            role.name
19686                        ))
19687                    })?;
19688                let columns = match permission {
19689                    crate::auth::Permission::SelectColumns { columns, .. }
19690                    | crate::auth::Permission::InsertColumns { columns, .. }
19691                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
19692                    _ => None,
19693                };
19694                if columns.is_some_and(|columns| {
19695                    columns.is_empty()
19696                        || columns.iter().any(|column| schema.column(column).is_none())
19697                }) {
19698                    return Err(MongrelError::Schema(format!(
19699                        "role {:?} contains invalid column permissions",
19700                        role.name
19701                    )));
19702                }
19703            }
19704        }
19705    }
19706    let mut user_ids = HashSet::new();
19707    let mut usernames = HashSet::new();
19708    let mut maximum_user_id = 0;
19709    for user in &catalog.users {
19710        maximum_user_id = maximum_user_id.max(user.id);
19711        if user.id == 0
19712            || user.username.is_empty()
19713            || user.password_hash.is_empty()
19714            || user.created_epoch > catalog.db_epoch
19715            || !user_ids.insert(user.id)
19716            || !usernames.insert(user.username.as_str())
19717            || user
19718                .roles
19719                .iter()
19720                .any(|role| !role_names.contains(role.as_str()))
19721        {
19722            return Err(MongrelError::Schema(format!(
19723                "invalid or duplicate user {:?}",
19724                user.username
19725            )));
19726        }
19727    }
19728    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
19729        return Err(MongrelError::Schema(
19730            "catalog next_user_id does not advance beyond existing user ids".into(),
19731        ));
19732    }
19733    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
19734        return Err(MongrelError::Schema(
19735            "authenticated catalog has no administrator".into(),
19736        ));
19737    }
19738    Ok(())
19739}
19740
19741fn validate_recovered_storage_plan(
19742    root: &Path,
19743    durable_root: Option<&crate::durable_file::DurableRoot>,
19744    catalog: &Catalog,
19745    created_table_ids: &HashSet<u64>,
19746    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
19747    meta_dek: Option<&[u8; META_DEK_LEN]>,
19748) -> Result<Vec<u64>> {
19749    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
19750    let mut reconcile = Vec::new();
19751    for entry in &catalog.tables {
19752        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19753            continue;
19754        }
19755        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19756        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
19757        let table_exists = match durable_root {
19758            Some(root) => match root.open_directory(&relative_dir) {
19759                Ok(_) => true,
19760                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19761                Err(error) => return Err(error.into()),
19762            },
19763            None => table_dir.is_dir(),
19764        };
19765        if !table_exists {
19766            if created_table_ids.contains(&entry.table_id) {
19767                reconcile.push(entry.table_id);
19768                continue;
19769            }
19770            return Err(MongrelError::NotFound(format!(
19771                "catalog table {} storage is missing",
19772                entry.table_id
19773            )));
19774        }
19775        let manifest_result = match durable_root {
19776            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
19777            None => crate::manifest::read(&table_dir, meta_dek),
19778        };
19779        let manifest = match manifest_result {
19780            Ok(manifest) => manifest,
19781            Err(MongrelError::Io(error))
19782                if created_table_ids.contains(&entry.table_id)
19783                    && error.kind() == std::io::ErrorKind::NotFound =>
19784            {
19785                reconcile.push(entry.table_id);
19786                continue;
19787            }
19788            Err(error) => return Err(error),
19789        };
19790        if manifest.table_id != entry.table_id {
19791            return Err(MongrelError::Conflict(format!(
19792                "catalog table {} storage identity mismatch",
19793                entry.table_id
19794            )));
19795        }
19796        let schema_result = match durable_root {
19797            Some(root) => root
19798                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
19799                .map_err(MongrelError::from),
19800            None => crate::durable_file::open_regular_nofollow(
19801                &table_dir.join(crate::engine::SCHEMA_FILENAME),
19802            ),
19803        };
19804        let file = match schema_result {
19805            Ok(file) => file,
19806            Err(MongrelError::Io(error))
19807                if created_table_ids.contains(&entry.table_id)
19808                    && error.kind() == std::io::ErrorKind::NotFound =>
19809            {
19810                reconcile.push(entry.table_id);
19811                continue;
19812            }
19813            Err(error) => return Err(error),
19814        };
19815        let length = file.metadata()?.len();
19816        if length > MAX_SCHEMA_BYTES {
19817            return Err(MongrelError::ResourceLimitExceeded {
19818                resource: "recovered schema bytes",
19819                requested: usize::try_from(length).unwrap_or(usize::MAX),
19820                limit: MAX_SCHEMA_BYTES as usize,
19821            });
19822        }
19823        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
19824            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
19825        if manifest.schema_id != entry.schema.schema_id
19826            || crate::wal::DdlOp::encode_schema(&disk_schema)?
19827                != crate::wal::DdlOp::encode_schema(&entry.schema)?
19828        {
19829            reconcile.push(entry.table_id);
19830        }
19831    }
19832    for table_id in ttl_updates.keys() {
19833        if !catalog.tables.iter().any(|entry| {
19834            entry.table_id == *table_id
19835                && matches!(entry.state, TableState::Live | TableState::Building { .. })
19836        }) {
19837            continue;
19838        }
19839        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
19840        let table_exists = match durable_root {
19841            Some(root) => match root.open_directory(&relative_dir) {
19842                Ok(_) => true,
19843                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19844                Err(error) => return Err(error.into()),
19845            },
19846            None => root.join(&relative_dir).is_dir(),
19847        };
19848        if !table_exists && !created_table_ids.contains(table_id) {
19849            return Err(MongrelError::NotFound(format!(
19850                "TTL recovery table {table_id} storage is missing"
19851            )));
19852        }
19853    }
19854    reconcile.sort_unstable();
19855    reconcile.dedup();
19856    Ok(reconcile)
19857}
19858
19859fn validate_catalog_table_storage(
19860    root: &crate::durable_file::DurableRoot,
19861    catalog: &Catalog,
19862    meta_dek: Option<&[u8; META_DEK_LEN]>,
19863) -> Result<()> {
19864    for entry in &catalog.tables {
19865        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19866            continue;
19867        }
19868        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19869        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
19870        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
19871            return Err(MongrelError::Conflict(format!(
19872                "catalog table {} storage identity mismatch",
19873                entry.table_id
19874            )));
19875        }
19876        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
19877    }
19878    Ok(())
19879}
19880
19881fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
19882    match schema.columns.iter_mut().find(|c| c.id == column.id) {
19883        Some(existing) if *existing == column => Ok(false),
19884        Some(existing) => {
19885            *existing = column;
19886            schema.schema_id = schema
19887                .schema_id
19888                .checked_add(1)
19889                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19890            Ok(true)
19891        }
19892        None => {
19893            schema.columns.push(column);
19894            schema.schema_id = schema
19895                .schema_id
19896                .checked_add(1)
19897                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19898            Ok(true)
19899        }
19900    }
19901}
19902
19903fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
19904    use crate::auth::Permission;
19905    match permission {
19906        Permission::Select { table }
19907        | Permission::Insert { table }
19908        | Permission::Update { table }
19909        | Permission::Delete { table }
19910        | Permission::SelectColumns { table, .. }
19911        | Permission::InsertColumns { table, .. }
19912        | Permission::UpdateColumns { table, .. } => Some(table),
19913        Permission::All | Permission::Ddl | Permission::Admin => None,
19914    }
19915}
19916
19917fn apply_rebuilding_publish(
19918    catalog: &mut Catalog,
19919    table_id: u64,
19920    replaced_table_id: u64,
19921    new_name: &str,
19922    epoch: u64,
19923) -> Result<bool> {
19924    let already_published = catalog.tables.iter().any(|entry| {
19925        entry.table_id == table_id
19926            && entry.name == new_name
19927            && matches!(entry.state, TableState::Live)
19928    }) && catalog.tables.iter().any(|entry| {
19929        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
19930    });
19931    if already_published {
19932        return Ok(false);
19933    }
19934    let schema = catalog
19935        .tables
19936        .iter()
19937        .find(|entry| entry.table_id == table_id)
19938        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
19939        .schema
19940        .clone();
19941    let replaced = catalog
19942        .tables
19943        .iter_mut()
19944        .find(|entry| entry.table_id == replaced_table_id)
19945        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
19946    replaced.state = TableState::Dropped { at_epoch: epoch };
19947    let replacement = catalog
19948        .tables
19949        .iter_mut()
19950        .find(|entry| entry.table_id == table_id)
19951        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
19952    replacement.name = new_name.to_string();
19953    replacement.state = TableState::Live;
19954
19955    for role in &mut catalog.roles {
19956        role.permissions.retain_mut(|permission| {
19957            retain_rebuilt_permission_columns(permission, new_name, &schema)
19958        });
19959    }
19960    for definition in &mut catalog.materialized_views {
19961        if let Some(incremental) = definition.incremental.as_mut() {
19962            if incremental.source_table == new_name
19963                && incremental.source_table_id == replaced_table_id
19964            {
19965                incremental.source_table_id = table_id;
19966            }
19967        }
19968    }
19969    advance_security_version(catalog)?;
19970    Ok(true)
19971}
19972
19973fn retain_rebuilt_permission_columns(
19974    permission: &mut crate::auth::Permission,
19975    target_table: &str,
19976    schema: &Schema,
19977) -> bool {
19978    use crate::auth::Permission;
19979    let columns = match permission {
19980        Permission::SelectColumns { table, columns }
19981        | Permission::InsertColumns { table, columns }
19982        | Permission::UpdateColumns { table, columns }
19983            if table == target_table =>
19984        {
19985            Some(columns)
19986        }
19987        _ => None,
19988    };
19989    if let Some(columns) = columns {
19990        columns.retain(|column| schema.column(column).is_some());
19991        !columns.is_empty()
19992    } else {
19993        true
19994    }
19995}
19996
19997fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
19998    use crate::auth::Permission;
19999    let table = match permission {
20000        Permission::Select { table }
20001        | Permission::Insert { table }
20002        | Permission::Update { table }
20003        | Permission::Delete { table }
20004        | Permission::SelectColumns { table, .. }
20005        | Permission::InsertColumns { table, .. }
20006        | Permission::UpdateColumns { table, .. } => Some(table),
20007        Permission::All | Permission::Ddl | Permission::Admin => None,
20008    };
20009    if let Some(table) = table.filter(|table| table.as_str() == old) {
20010        *table = new.to_string();
20011    }
20012}
20013
20014fn rename_permission_column(
20015    permission: &mut crate::auth::Permission,
20016    target_table: &str,
20017    old: &str,
20018    new: &str,
20019) {
20020    use crate::auth::Permission;
20021    let columns = match permission {
20022        Permission::SelectColumns { table, columns }
20023        | Permission::InsertColumns { table, columns }
20024        | Permission::UpdateColumns { table, columns }
20025            if table == target_table =>
20026        {
20027            Some(columns)
20028        }
20029        _ => None,
20030    };
20031    if let Some(column) = columns
20032        .into_iter()
20033        .flatten()
20034        .find(|column| column.as_str() == old)
20035    {
20036        *column = new.to_string();
20037    }
20038}
20039
20040pub(crate) fn validate_security_catalog(
20041    catalog: &Catalog,
20042    security: &crate::security::SecurityCatalog,
20043) -> Result<()> {
20044    let mut policy_names = HashSet::new();
20045    for table in &security.rls_tables {
20046        if catalog.live(table).is_none() {
20047            return Err(MongrelError::NotFound(format!(
20048                "RLS table {table:?} not found"
20049            )));
20050        }
20051    }
20052    for policy in &security.policies {
20053        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
20054            return Err(MongrelError::InvalidArgument(format!(
20055                "duplicate policy {:?} on {:?}",
20056                policy.name, policy.table
20057            )));
20058        }
20059        let schema = &catalog
20060            .live(&policy.table)
20061            .ok_or_else(|| {
20062                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
20063            })?
20064            .schema;
20065        if let Some(expression) = &policy.using {
20066            validate_security_expression(expression, schema)?;
20067        }
20068        if let Some(expression) = &policy.with_check {
20069            validate_security_expression(expression, schema)?;
20070        }
20071    }
20072    let mut mask_names = HashSet::new();
20073    for mask in &security.masks {
20074        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
20075            return Err(MongrelError::InvalidArgument(format!(
20076                "duplicate mask {:?} on {:?}",
20077                mask.name, mask.table
20078            )));
20079        }
20080        let column = catalog
20081            .live(&mask.table)
20082            .and_then(|entry| {
20083                entry
20084                    .schema
20085                    .columns
20086                    .iter()
20087                    .find(|column| column.id == mask.column)
20088            })
20089            .ok_or_else(|| {
20090                MongrelError::NotFound(format!(
20091                    "mask column {} on {:?} not found",
20092                    mask.column, mask.table
20093                ))
20094            })?;
20095        if matches!(
20096            mask.strategy,
20097            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
20098        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
20099        {
20100            return Err(MongrelError::InvalidArgument(format!(
20101                "mask {:?} requires a string/bytes column",
20102                mask.name
20103            )));
20104        }
20105    }
20106    Ok(())
20107}
20108
20109fn validate_security_expression(
20110    expression: &crate::security::SecurityExpr,
20111    schema: &Schema,
20112) -> Result<()> {
20113    use crate::security::SecurityExpr;
20114    match expression {
20115        SecurityExpr::True => Ok(()),
20116        SecurityExpr::ColumnEqCurrentUser { column }
20117        | SecurityExpr::ColumnEqValue { column, .. } => {
20118            if schema
20119                .columns
20120                .iter()
20121                .any(|candidate| candidate.id == *column)
20122            {
20123                Ok(())
20124            } else {
20125                Err(MongrelError::InvalidArgument(format!(
20126                    "security expression references unknown column id {column}"
20127                )))
20128            }
20129        }
20130        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
20131            validate_security_expression(left, schema)?;
20132            validate_security_expression(right, schema)
20133        }
20134        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
20135    }
20136}
20137
20138/// Remove canonical numeric table directories that no catalog generation owns.
20139fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
20140    let referenced = cat
20141        .tables
20142        .iter()
20143        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
20144        .map(|entry| entry.table_id)
20145        .collect::<HashSet<_>>();
20146    let tables_dir = root.join(TABLES_DIR);
20147    let entries = match std::fs::read_dir(&tables_dir) {
20148        Ok(entries) => entries,
20149        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
20150        Err(error) => return Err(error.into()),
20151    };
20152    for entry in entries {
20153        let entry = entry?;
20154        if !entry.file_type()?.is_dir() {
20155            continue;
20156        }
20157        let file_name = entry.file_name();
20158        let Some(name) = file_name.to_str() else {
20159            continue;
20160        };
20161        let Ok(table_id) = name.parse::<u64>() else {
20162            continue;
20163        };
20164        if name != table_id.to_string() {
20165            continue;
20166        }
20167        if !referenced.contains(&table_id) {
20168            crate::durable_file::remove_directory_all(&entry.path())?;
20169        }
20170    }
20171    Ok(())
20172}
20173
20174/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
20175/// #14). These dirs hold pending uniform-epoch runs from large transactions
20176/// that were aborted or crashed before commit. On open, all such dirs are safe
20177/// to remove because committed txns moved their runs to `_runs/` at publish.
20178fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
20179    for entry in &cat.tables {
20180        let txn_dir = root
20181            .join(TABLES_DIR)
20182            .join(entry.table_id.to_string())
20183            .join("_txn");
20184        if txn_dir.exists() {
20185            let _ = std::fs::remove_dir_all(&txn_dir);
20186        }
20187    }
20188}
20189
20190#[cfg(test)]
20191mod write_permission_tests {
20192    use super::*;
20193    use crate::txn::Staged;
20194
20195    struct NoopExternalBridge;
20196
20197    impl ExternalTriggerBridge for NoopExternalBridge {
20198        fn apply_trigger_external_write(
20199            &self,
20200            _entry: &ExternalTableEntry,
20201            base_state: Vec<u8>,
20202            _op: ExternalTriggerWrite,
20203        ) -> Result<ExternalTriggerWriteResult> {
20204            Ok(ExternalTriggerWriteResult::new(base_state))
20205        }
20206    }
20207
20208    fn assert_txn_namespace_full<T>(result: Result<T>) {
20209        assert!(matches!(result, Err(MongrelError::Full(_))));
20210    }
20211
20212    #[test]
20213    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
20214        let directory = tempfile::tempdir().unwrap();
20215        let database = Database::create(directory.path()).unwrap();
20216        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
20217        *database.next_txn_id.lock() = generation << 32;
20218        let before = crate::wal::SharedWal::replay(directory.path())
20219            .unwrap()
20220            .len();
20221        let bridge = NoopExternalBridge;
20222
20223        assert_txn_namespace_full(database.begin().commit());
20224        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
20225        assert_txn_namespace_full(
20226            database
20227                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
20228                .commit(),
20229        );
20230        assert_txn_namespace_full(
20231            database
20232                .begin_with_external_trigger_bridge(&bridge)
20233                .commit(),
20234        );
20235        assert_txn_namespace_full(
20236            database
20237                .begin_with_external_trigger_bridge_as(&bridge, None)
20238                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
20239        );
20240
20241        assert_eq!(
20242            crate::wal::SharedWal::replay(directory.path())
20243                .unwrap()
20244                .len(),
20245            before
20246        );
20247        drop(database);
20248        Database::open(directory.path()).unwrap();
20249    }
20250
20251    #[test]
20252    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
20253        let directory = tempfile::tempdir().unwrap();
20254        let table_dir = directory.path().join("7");
20255        crate::durable_file::create_directory_all(&table_dir).unwrap();
20256        let original_schema = test_schema();
20257        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
20258        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
20259        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
20260        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
20261        let original_bytes = std::fs::read(&schema_path).unwrap();
20262
20263        let mut replacement_schema = original_schema;
20264        replacement_schema.schema_id += 1;
20265        assert!(matches!(
20266            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
20267            Err(MongrelError::Conflict(_))
20268        ));
20269
20270        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
20271        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
20272        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
20273        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
20274    }
20275
20276    #[test]
20277    fn catalog_table_missing_storage_fails_without_recreating_it() {
20278        let directory = tempfile::tempdir().unwrap();
20279        let table_dir = {
20280            let database = Database::create(directory.path()).unwrap();
20281            database.create_table("docs", test_schema()).unwrap();
20282            directory
20283                .path()
20284                .join(TABLES_DIR)
20285                .join(database.table_id("docs").unwrap().to_string())
20286        };
20287        std::fs::remove_dir_all(&table_dir).unwrap();
20288
20289        assert!(matches!(
20290            Database::open(directory.path()),
20291            Err(MongrelError::NotFound(_))
20292        ));
20293        assert!(!table_dir.exists());
20294    }
20295
20296    #[test]
20297    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
20298        let directory = tempfile::tempdir().unwrap();
20299        let database = std::sync::Arc::new(
20300            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
20301        );
20302        database.create_user("alice", "old-password").unwrap();
20303        let old_identity = database.user_identity("alice").unwrap();
20304        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
20305        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
20306        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
20307        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
20308
20309        std::thread::scope(|scope| {
20310            let authenticate = {
20311                let database = std::sync::Arc::clone(&database);
20312                scope.spawn(move || {
20313                    database.authenticate_principal_inner("alice", "old-password", || {
20314                        verified_tx.send(()).unwrap();
20315                        resume_rx.recv().unwrap();
20316                    })
20317                })
20318            };
20319            verified_rx.recv().unwrap();
20320            let mutate = {
20321                let database = std::sync::Arc::clone(&database);
20322                scope.spawn(move || {
20323                    mutation_started_tx.send(()).unwrap();
20324                    database.drop_user("alice").unwrap();
20325                    database.create_user("alice", "new-password").unwrap();
20326                    mutation_done_tx.send(()).unwrap();
20327                })
20328            };
20329            mutation_started_rx.recv().unwrap();
20330            assert!(mutation_done_rx
20331                .recv_timeout(std::time::Duration::from_millis(50))
20332                .is_err());
20333            resume_tx.send(()).unwrap();
20334            let principal = authenticate.join().unwrap().unwrap().unwrap();
20335            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
20336            mutate.join().unwrap();
20337        });
20338
20339        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
20340        assert!(database
20341            .authenticate_principal("alice", "old-password")
20342            .unwrap()
20343            .is_none());
20344        assert!(database
20345            .authenticate_principal("alice", "new-password")
20346            .unwrap()
20347            .is_some());
20348    }
20349
20350    #[test]
20351    fn homogeneous_batch_summarizes_to_one_permission_decision() {
20352        let staging = (0..10_050)
20353            .map(|_| {
20354                (
20355                    7,
20356                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
20357                )
20358            })
20359            .collect::<Vec<_>>();
20360
20361        let needs = summarize_write_permissions(&staging);
20362        let table = needs.get(&7).unwrap();
20363        assert_eq!(needs.len(), 1);
20364        assert!(table.insert);
20365        assert_eq!(table.insert_columns, [1, 2]);
20366        assert!(!table.update);
20367        assert!(!table.delete);
20368        assert!(!table.truncate);
20369    }
20370
20371    #[test]
20372    fn mixed_writes_union_columns_and_preserve_empty_operations() {
20373        let staging = vec![
20374            (7, Staged::Put(vec![(2, Value::Int64(2))])),
20375            (7, Staged::Put(vec![(1, Value::Int64(1))])),
20376            (
20377                7,
20378                Staged::Update {
20379                    row_id: RowId(1),
20380                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
20381                    changed_columns: vec![2],
20382                },
20383            ),
20384            (7, Staged::Delete(RowId(2))),
20385            (8, Staged::Truncate),
20386        ];
20387
20388        let needs = summarize_write_permissions(&staging);
20389        let table = needs.get(&7).unwrap();
20390        assert_eq!(table.insert_columns, [1, 2]);
20391        assert!(table.update);
20392        assert_eq!(table.update_columns, [2]);
20393        assert!(table.delete);
20394        assert!(needs.get(&8).unwrap().truncate);
20395    }
20396
20397    #[test]
20398    fn final_permission_decisions_do_not_scale_with_rows() {
20399        let credentialless_dir = tempfile::tempdir().unwrap();
20400        let credentialless = Database::create(credentialless_dir.path()).unwrap();
20401        credentialless.create_table("docs", test_schema()).unwrap();
20402        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20403        credentialless
20404            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
20405            .unwrap();
20406        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
20407
20408        let authenticated_dir = tempfile::tempdir().unwrap();
20409        let authenticated =
20410            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
20411                .unwrap();
20412        authenticated.create_table("docs", test_schema()).unwrap();
20413        let admin = authenticated.resolve_principal("admin").unwrap();
20414        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20415        authenticated
20416            .validate_write_permissions(
20417                &puts(authenticated.table_id("docs").unwrap()),
20418                Some(&admin),
20419                None,
20420            )
20421            .unwrap();
20422        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20423    }
20424
20425    #[test]
20426    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
20427        let dir = tempfile::tempdir().unwrap();
20428        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20429        db.create_table("docs", test_schema()).unwrap();
20430        let admin = db.resolve_principal("admin").unwrap();
20431        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20432
20433        let mut transaction = db.begin_as(Some(admin));
20434        transaction
20435            .delete_batch("docs", (0..100).map(RowId).collect())
20436            .unwrap();
20437        transaction.commit().unwrap();
20438
20439        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
20440    }
20441
20442    #[test]
20443    fn truncate_validation_checks_admin_once_for_all_tables() {
20444        let dir = tempfile::tempdir().unwrap();
20445        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20446        db.create_table("first", test_schema()).unwrap();
20447        db.create_table("second", test_schema()).unwrap();
20448        let admin = db.resolve_principal("admin").unwrap();
20449        let staging = vec![
20450            (db.table_id("first").unwrap(), Staged::Truncate),
20451            (db.table_id("second").unwrap(), Staged::Truncate),
20452        ];
20453
20454        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20455        db.validate_write_permissions(&staging, Some(&admin), None)
20456            .unwrap();
20457        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20458    }
20459
20460    #[test]
20461    fn one_table_commit_batches_structural_work() {
20462        let dir = tempfile::tempdir().unwrap();
20463        let db = Database::create(dir.path()).unwrap();
20464        db.create_table("docs", test_schema()).unwrap();
20465        let table_id = db.table_id("docs").unwrap();
20466
20467        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
20468        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20469        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20470        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20471        db.transaction(|transaction| {
20472            for id in 0..100 {
20473                transaction.put("docs", vec![(1, Value::Int64(id))])?;
20474            }
20475            Ok(())
20476        })
20477        .unwrap();
20478
20479        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
20480        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20481        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20482        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20483
20484        let puts = crate::wal::SharedWal::replay(dir.path())
20485            .unwrap()
20486            .into_iter()
20487            .filter_map(|record| match record.op {
20488                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
20489                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
20490                        .unwrap()
20491                        .len(),
20492                ),
20493                _ => None,
20494            })
20495            .collect::<Vec<_>>();
20496        assert_eq!(puts, [100]);
20497
20498        let row_ids = db
20499            .table("docs")
20500            .unwrap()
20501            .lock()
20502            .visible_rows(db.snapshot().0)
20503            .unwrap()
20504            .into_iter()
20505            .take(2)
20506            .map(|row| row.row_id)
20507            .collect::<Vec<_>>();
20508        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20509        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20510        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20511        db.transaction(|transaction| {
20512            for row_id in row_ids {
20513                transaction.delete("docs", row_id)?;
20514            }
20515            Ok(())
20516        })
20517        .unwrap();
20518        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20519        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20520        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20521
20522        let deletes = crate::wal::SharedWal::replay(dir.path())
20523            .unwrap()
20524            .into_iter()
20525            .filter_map(|record| match record.op {
20526                crate::wal::Op::Delete {
20527                    table_id: id,
20528                    row_ids,
20529                } if id == table_id => Some(row_ids.len()),
20530                _ => None,
20531            })
20532            .collect::<Vec<_>>();
20533        assert_eq!(deletes, [2]);
20534    }
20535
20536    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
20537        (0..10_050)
20538            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
20539            .collect()
20540    }
20541
20542    fn test_schema() -> Schema {
20543        Schema {
20544            columns: vec![ColumnDef {
20545                id: 1,
20546                name: "id".into(),
20547                ty: TypeId::Int64,
20548                flags: crate::schema::ColumnFlags::empty()
20549                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20550                default_value: None,
20551                embedding_source: None,
20552            }],
20553            ..Schema::default()
20554        }
20555    }
20556}
20557
20558#[cfg(test)]
20559mod cdc_bounds_tests {
20560    use super::*;
20561
20562    #[test]
20563    fn retained_byte_limit_rejects_without_allocating_payload() {
20564        let mut retained = 0;
20565        let error = charge_cdc_bytes(
20566            &mut retained,
20567            CDC_MAX_RETAINED_BYTES.saturating_add(1),
20568            "CDC retained bytes",
20569        )
20570        .unwrap_err();
20571        assert!(matches!(
20572            error,
20573            MongrelError::ResourceLimitExceeded {
20574                resource: "CDC retained bytes",
20575                ..
20576            }
20577        ));
20578    }
20579
20580    #[test]
20581    fn row_json_estimate_accounts_for_byte_array_expansion() {
20582        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
20583            .with_column(1, Value::Bytes(vec![0; 1024]));
20584        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
20585    }
20586}
20587
20588#[cfg(test)]
20589mod generation_metrics_tests {
20590    use super::*;
20591    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20592
20593    #[test]
20594    fn legacy_cow_fallback_is_measured() {
20595        let dir = tempfile::tempdir().unwrap();
20596        let table = Table::create(
20597            dir.path(),
20598            Schema {
20599                columns: vec![ColumnDef {
20600                    id: 1,
20601                    name: "id".into(),
20602                    ty: TypeId::Int64,
20603                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20604                    default_value: None,
20605                    embedding_source: None,
20606                }],
20607                ..Schema::default()
20608            },
20609            1,
20610        )
20611        .unwrap();
20612        let handle = TableHandle::from_table(table);
20613        let held = match &handle.inner {
20614            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
20615            TableHandleInner::Direct(_) => unreachable!(),
20616        };
20617
20618        handle.lock().set_sync_byte_threshold(1);
20619
20620        let stats = handle.generation_stats();
20621        assert_eq!(stats.cow_clone_count, 1);
20622        assert!(stats.estimated_cow_clone_bytes > 0);
20623        drop(held);
20624    }
20625}
20626
20627#[cfg(test)]
20628mod trigger_engine_tests {
20629    use super::*;
20630
20631    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
20632        WriteEvent {
20633            table: "test".into(),
20634            kind: TriggerEvent::Insert,
20635            new: Some(TriggerRowImage {
20636                columns: new_cells.iter().cloned().collect(),
20637            }),
20638            old: Some(TriggerRowImage {
20639                columns: old_cells.iter().cloned().collect(),
20640            }),
20641            changed_columns: Vec::new(),
20642            op_indices: Vec::new(),
20643            put_idx: None,
20644            trigger_stack: Vec::new(),
20645        }
20646    }
20647
20648    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
20649        WriteEvent {
20650            table: "test".into(),
20651            kind: TriggerEvent::Insert,
20652            new: Some(TriggerRowImage {
20653                columns: new_cells.iter().cloned().collect(),
20654            }),
20655            old: None,
20656            changed_columns: Vec::new(),
20657            op_indices: Vec::new(),
20658            put_idx: None,
20659            trigger_stack: Vec::new(),
20660        }
20661    }
20662
20663    #[test]
20664    fn value_order_int64_vs_float64() {
20665        assert_eq!(
20666            value_order(&Value::Int64(5), &Value::Float64(5.0)),
20667            Some(std::cmp::Ordering::Equal)
20668        );
20669        assert_eq!(
20670            value_order(&Value::Int64(5), &Value::Float64(3.0)),
20671            Some(std::cmp::Ordering::Greater)
20672        );
20673        assert_eq!(
20674            value_order(&Value::Int64(2), &Value::Float64(3.0)),
20675            Some(std::cmp::Ordering::Less)
20676        );
20677    }
20678
20679    #[test]
20680    fn value_order_null_returns_none() {
20681        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
20682        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
20683        assert_eq!(value_order(&Value::Null, &Value::Null), None);
20684    }
20685
20686    #[test]
20687    fn value_order_cross_group_returns_none() {
20688        assert_eq!(
20689            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
20690            None
20691        );
20692        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
20693        assert_eq!(
20694            value_order(
20695                &Value::Embedding(vec![1.0, 2.0]),
20696                &Value::Embedding(vec![1.0, 2.0])
20697            ),
20698            None
20699        );
20700    }
20701
20702    #[test]
20703    fn eval_trigger_expr_ranges_and_booleans() {
20704        let expr = TriggerExpr::And {
20705            left: Box::new(TriggerExpr::Gt {
20706                left: TriggerValue::NewColumn(1),
20707                right: TriggerValue::Literal(Value::Int64(0)),
20708            }),
20709            right: Box::new(TriggerExpr::Lte {
20710                left: TriggerValue::NewColumn(1),
20711                right: TriggerValue::Literal(Value::Int64(100)),
20712            }),
20713        };
20714        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
20715        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
20716        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
20717
20718        let or_expr = TriggerExpr::Or {
20719            left: Box::new(TriggerExpr::Lt {
20720                left: TriggerValue::NewColumn(1),
20721                right: TriggerValue::Literal(Value::Int64(0)),
20722            }),
20723            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
20724                TriggerValue::OldColumn(2),
20725            )))),
20726        };
20727        assert!(eval_trigger_expr(
20728            &or_expr,
20729            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
20730        )
20731        .unwrap());
20732        assert!(!eval_trigger_expr(
20733            &or_expr,
20734            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
20735        )
20736        .unwrap());
20737
20738        assert!(eval_trigger_expr(
20739            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
20740            &event_insert(&[])
20741        )
20742        .unwrap());
20743        assert!(!eval_trigger_expr(
20744            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
20745            &event_insert(&[])
20746        )
20747        .unwrap());
20748        assert!(!eval_trigger_expr(
20749            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
20750            &event_insert(&[])
20751        )
20752        .unwrap());
20753    }
20754}
20755
20756#[cfg(test)]
20757mod core_resource_tests {
20758    use super::*;
20759
20760    fn int_pk_schema() -> Schema {
20761        Schema {
20762            columns: vec![ColumnDef {
20763                id: 1,
20764                name: "id".into(),
20765                ty: TypeId::Int64,
20766                flags: crate::schema::ColumnFlags::empty()
20767                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20768                default_value: None,
20769                embedding_source: None,
20770            }],
20771            ..Schema::default()
20772        }
20773    }
20774
20775    #[test]
20776    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
20777        let dir = tempfile::tempdir().unwrap();
20778        let db = Database::create(dir.path()).unwrap();
20779        assert_eq!(
20780            db.memory_governor().max_bytes(),
20781            DEFAULT_MEMORY_BUDGET_BYTES
20782        );
20783        assert_eq!(
20784            db.spill_manager().config().global_bytes,
20785            DEFAULT_TEMP_DISK_BUDGET_BYTES
20786        );
20787        assert!(db.job_registry().list().is_empty());
20788        // S1E-002: class defaults seeded at open; no external embedding vendor.
20789        assert_eq!(
20790            db.resource_groups().len(),
20791            crate::resource::WorkloadClass::ALL.len()
20792        );
20793        assert!(db.resource_groups().get("control").is_some());
20794        assert!(db.embedding_providers().list_ids().is_empty());
20795        // Application-supplied path refuses generation (no silent hashed vectors).
20796        let err = db
20797            .embedding_providers()
20798            .embed(
20799                &crate::embedding::EmbeddingSource::SuppliedByApplication,
20800                &["text"],
20801                4,
20802            )
20803            .unwrap_err();
20804        assert!(matches!(
20805            err,
20806            crate::embedding::EmbeddingError::SuppliedByApplication
20807        ));
20808    }
20809
20810    #[test]
20811    fn lock_rows_for_update_acquires_exclusive_row_locks() {
20812        use crate::locks::LockKey;
20813        use crate::rowid::RowId;
20814
20815        let dir = tempfile::tempdir().unwrap();
20816        let db = Database::create(dir.path()).unwrap();
20817        let txn_id = db.allocate_lock_txn_id().unwrap();
20818        let rid = RowId(42);
20819        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
20820        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20821        db.release_txn_locks(txn_id);
20822        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20823    }
20824
20825    #[test]
20826    fn open_with_options_sizes_the_core_budgets() {
20827        let dir = tempfile::tempdir().unwrap();
20828        let db = Database::create(dir.path()).unwrap();
20829        drop(db);
20830        let db = Database::open_with_options(
20831            dir.path(),
20832            OpenOptions::default()
20833                .with_memory_budget_bytes(256 * 1024 * 1024)
20834                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
20835        )
20836        .unwrap();
20837        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
20838        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
20839    }
20840
20841    #[test]
20842    fn zero_budgets_are_rejected() {
20843        let dir = tempfile::tempdir().unwrap();
20844        let db = Database::create(dir.path()).unwrap();
20845        drop(db);
20846        let result = Database::open_with_options(
20847            dir.path(),
20848            OpenOptions::default().with_memory_budget_bytes(0),
20849        );
20850        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20851        let result = Database::open_with_options(
20852            dir.path(),
20853            OpenOptions::default().with_temp_disk_budget_bytes(0),
20854        );
20855        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20856    }
20857
20858    #[test]
20859    fn page_caches_reserve_under_the_governor() {
20860        let dir = tempfile::tempdir().unwrap();
20861        let db = Database::create(dir.path()).unwrap();
20862        db.create_table("t", int_pk_schema()).unwrap();
20863        let mut txn = db.begin();
20864        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20865        txn.commit().unwrap();
20866        // S1E-003: both caches hold reservations under the core's governor, so
20867        // the governor's per-class accounting mirrors their live bytes, and
20868        // both are registered as reclaimable subsystems it can evict.
20869        let stats = db.memory_governor().stats();
20870        assert_eq!(
20871            stats.usage_for(crate::memory::MemoryClass::PageCache),
20872            db.page_cache.used_bytes()
20873        );
20874        assert_eq!(
20875            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
20876            db.decoded_cache.used_bytes()
20877        );
20878        assert_eq!(
20879            db.memory_governor().reclaimable_bytes(),
20880            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
20881        );
20882        // Driving an eviction through the governor is safe at any level.
20883        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
20884    }
20885
20886    #[test]
20887    fn job_registry_persists_across_reopen() {
20888        let dir = tempfile::tempdir().unwrap();
20889        let db = Database::create(dir.path()).unwrap();
20890        db.create_table("t", int_pk_schema()).unwrap();
20891        let job_id = db
20892            .job_registry()
20893            .submit(
20894                crate::jobs::JobKind::IndexBuild,
20895                crate::jobs::JobTarget {
20896                    table: "t".to_string(),
20897                    index: Some("idx".to_string()),
20898                },
20899            )
20900            .unwrap();
20901        drop(db);
20902        let db = Database::open(dir.path()).unwrap();
20903        let record = db.job_registry().get(job_id).expect("job survives reopen");
20904        assert_eq!(record.state, crate::jobs::JobState::Pending);
20905    }
20906
20907    #[test]
20908    fn spill_manager_open_sweeps_stale_temp_tree() {
20909        let dir = tempfile::tempdir().unwrap();
20910        let db = Database::create(dir.path()).unwrap();
20911        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
20912        std::fs::create_dir_all(&stale).unwrap();
20913        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
20914        drop(db);
20915        let db = Database::open(dir.path()).unwrap();
20916        assert!(
20917            !stale.exists(),
20918            "the startup sweep removes stale spill files (S1E-004)"
20919        );
20920        // A fresh session can start against the swept manager.
20921        let session = db
20922            .spill_manager()
20923            .begin_query(
20924                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
20925                1024 * 1024,
20926            )
20927            .unwrap();
20928        assert_eq!(session.used(), 0);
20929    }
20930}
20931
20932#[cfg(test)]
20933mod version_pin_tests {
20934    use super::*;
20935
20936    fn int_pk_schema() -> Schema {
20937        Schema {
20938            columns: vec![ColumnDef {
20939                id: 1,
20940                name: "id".into(),
20941                ty: TypeId::Int64,
20942                flags: crate::schema::ColumnFlags::empty()
20943                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20944                default_value: None,
20945                embedding_source: None,
20946            }],
20947            ..Schema::default()
20948        }
20949    }
20950
20951    fn pins_for(
20952        report: &[TablePinsReport],
20953        table: &str,
20954        source: crate::retention::PinSource,
20955    ) -> Option<crate::retention::PinInfo> {
20956        report
20957            .iter()
20958            .find(|entry| entry.table == table)
20959            .and_then(|entry| entry.pins.get(source).cloned())
20960    }
20961
20962    #[test]
20963    fn backup_boundary_registers_backup_pitr_pin() {
20964        let source = tempfile::tempdir().unwrap();
20965        let destination_parent = tempfile::tempdir().unwrap();
20966        let destination = destination_parent.path().join("backup");
20967        let db = Arc::new(Database::create(source.path()).unwrap());
20968        db.create_table("t", int_pk_schema()).unwrap();
20969        let mut txn = db.begin();
20970        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
20971        let boundary_epoch = txn.commit().unwrap();
20972
20973        let hold = Arc::new(std::sync::Barrier::new(2));
20974        let resume = Arc::new(std::sync::Barrier::new(2));
20975        db.__set_backup_hook({
20976            let hold = Arc::clone(&hold);
20977            let resume = Arc::clone(&resume);
20978            move || {
20979                hold.wait();
20980                resume.wait();
20981            }
20982        });
20983
20984        let backup = {
20985            let db = Arc::clone(&db);
20986            let destination = destination.clone();
20987            std::thread::spawn(move || db.hot_backup(destination))
20988        };
20989        hold.wait();
20990        // The hook fires while the backup's pins are held: the boundary must
20991        // show up as a BackupPitr pin on the table's unified registry.
20992        let report = db.version_pins_report();
20993        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
20994            .expect("backup boundary must register a BackupPitr pin");
20995        assert_eq!(pin.oldest_epoch, boundary_epoch);
20996        assert!(pin.pin_count >= 1);
20997        resume.wait();
20998        backup.join().unwrap().unwrap();
20999
21000        let report = db.version_pins_report();
21001        assert!(
21002            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
21003            "the BackupPitr pin releases when the backup finishes"
21004        );
21005    }
21006
21007    #[test]
21008    fn snapshot_and_read_generation_pins_surface_in_report() {
21009        let dir = tempfile::tempdir().unwrap();
21010        let db = Database::create(dir.path()).unwrap();
21011        db.create_table("t", int_pk_schema()).unwrap();
21012        let mut txn = db.begin();
21013        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
21014        txn.commit().unwrap();
21015
21016        let (_snapshot, guard) = db.snapshot();
21017        let report = db.version_pins_report();
21018        assert!(
21019            pins_for(
21020                &report,
21021                "t",
21022                crate::retention::PinSource::TransactionSnapshot
21023            )
21024            .is_some(),
21025            "a database snapshot projects the TransactionSnapshot source"
21026        );
21027        drop(guard);
21028
21029        let handle = db.table("t").unwrap();
21030        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
21031        let report = db.version_pins_report();
21032        assert!(
21033            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
21034            "a cloned read generation registers a ReadGeneration pin"
21035        );
21036        drop(generation);
21037
21038        let report = db.version_pins_report();
21039        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
21040        assert!(
21041            entry
21042                .pins
21043                .get(crate::retention::PinSource::BackupPitr)
21044                .is_none()
21045                && entry
21046                    .pins
21047                    .get(crate::retention::PinSource::Replication)
21048                    .is_none()
21049                && entry
21050                    .pins
21051                    .get(crate::retention::PinSource::OnlineIndexBuild)
21052                    .is_none(),
21053            "untaken sources stay absent from the report"
21054        );
21055    }
21056}
21057
21058#[cfg(test)]
21059mod lock_manager_tests {
21060    use super::*;
21061    use crate::locks::LockKey;
21062
21063    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
21064        ColumnDef {
21065            id,
21066            name: name.into(),
21067            ty,
21068            flags,
21069            default_value: None,
21070            embedding_source: None,
21071        }
21072    }
21073
21074    fn unique_schema() -> Schema {
21075        let mut constraints = crate::constraint::TableConstraints::default();
21076        constraints
21077            .uniques
21078            .push(crate::constraint::UniqueConstraint {
21079                id: 1,
21080                name: "users_email_unique".into(),
21081                columns: vec![1],
21082            });
21083        Schema {
21084            columns: vec![
21085                col(
21086                    0,
21087                    "id",
21088                    TypeId::Int64,
21089                    crate::schema::ColumnFlags::empty()
21090                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21091                ),
21092                col(
21093                    1,
21094                    "email",
21095                    TypeId::Bytes,
21096                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
21097                ),
21098            ],
21099            constraints,
21100            ..Schema::default()
21101        }
21102    }
21103
21104    fn parent_schema() -> Schema {
21105        Schema {
21106            columns: vec![col(
21107                0,
21108                "id",
21109                TypeId::Int64,
21110                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
21111            )],
21112            ..Schema::default()
21113        }
21114    }
21115
21116    fn child_schema() -> Schema {
21117        let mut constraints = crate::constraint::TableConstraints::default();
21118        constraints
21119            .foreign_keys
21120            .push(crate::constraint::ForeignKey {
21121                id: 1,
21122                name: "child_parent_fk".into(),
21123                columns: vec![1],
21124                ref_table: "parent".into(),
21125                ref_columns: vec![0],
21126                on_delete: crate::constraint::FkAction::Restrict,
21127                on_update: crate::constraint::FkAction::Restrict,
21128            });
21129        Schema {
21130            columns: vec![
21131                col(
21132                    0,
21133                    "id",
21134                    TypeId::Int64,
21135                    crate::schema::ColumnFlags::empty()
21136                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21137                ),
21138                col(
21139                    1,
21140                    "pid",
21141                    TypeId::Int64,
21142                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
21143                ),
21144            ],
21145            constraints,
21146            ..Schema::default()
21147        }
21148    }
21149
21150    fn auto_inc_schema() -> Schema {
21151        Schema {
21152            columns: vec![col(
21153                0,
21154                "id",
21155                TypeId::Int64,
21156                crate::schema::ColumnFlags::empty()
21157                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
21158                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
21159            )],
21160            ..Schema::default()
21161        }
21162    }
21163
21164    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
21165        let mut key = b"pk:".to_vec();
21166        key.extend_from_slice(&Value::Int64(value).encode_key());
21167        LockKey::key(table_id, key)
21168    }
21169
21170    #[test]
21171    fn unique_claims_serialize_concurrent_commits() {
21172        let dir = tempfile::tempdir().unwrap();
21173        let db = Arc::new(Database::create(dir.path()).unwrap());
21174        let table_id = db.create_table("users", unique_schema()).unwrap();
21175        let pk_key = pk_lock_key(table_id, 1);
21176        let entered = Arc::new(std::sync::Barrier::new(2));
21177        let resume = Arc::new(std::sync::Barrier::new(2));
21178        let parked = Arc::new(AtomicBool::new(false));
21179        db.__set_catalog_commit_hook({
21180            let entered = Arc::clone(&entered);
21181            let resume = Arc::clone(&resume);
21182            let parked = Arc::clone(&parked);
21183            move || {
21184                // Park only the first commit to reach the sequencer; later
21185                // commits pass straight through.
21186                if !parked.swap(true, Ordering::SeqCst) {
21187                    entered.wait();
21188                    resume.wait();
21189                }
21190            }
21191        });
21192
21193        let mut txn_a = db.begin();
21194        txn_a
21195            .put(
21196                "users",
21197                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21198            )
21199            .unwrap();
21200        let a_id = txn_a.txn_id();
21201        let (a_tx, a_rx) = std::sync::mpsc::channel();
21202        let (b_tx, b_rx) = std::sync::mpsc::channel();
21203        std::thread::scope(|scope| {
21204            scope.spawn(|| {
21205                a_tx.send(txn_a.commit()).unwrap();
21206            });
21207            entered.wait();
21208            // A is parked in the sequencer holding its unique claims.
21209            assert!(
21210                db.lock_manager().holds(a_id, &pk_key),
21211                "primary-key claim must be held until the commit ends"
21212            );
21213            let mut uq_key = format!("uq{}:", 1).into_bytes();
21214            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
21215                .into_iter()
21216                .collect();
21217            uq_key.extend_from_slice(
21218                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
21219            );
21220            assert!(
21221                db.lock_manager()
21222                    .holds(a_id, &LockKey::key(table_id, uq_key)),
21223                "declared-unique claim must be held until the commit ends"
21224            );
21225
21226            let mut txn_b = db.begin();
21227            txn_b
21228                .put(
21229                    "users",
21230                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
21231                )
21232                .unwrap();
21233            scope.spawn(|| {
21234                b_tx.send(txn_b.commit()).unwrap();
21235            });
21236            std::thread::sleep(std::time::Duration::from_millis(100));
21237            assert!(
21238                b_rx.try_recv().is_err(),
21239                "the concurrent claim must block until A ends its transaction"
21240            );
21241            resume.wait();
21242            assert!(a_rx.recv().unwrap().is_ok());
21243            let b_result = b_rx.recv().unwrap();
21244            assert!(
21245                matches!(b_result, Err(MongrelError::Conflict(_))),
21246                "the loser surfaces a conflict after serializing: {b_result:?}"
21247            );
21248        });
21249        assert!(
21250            !db.lock_manager().holds(a_id, &pk_key),
21251            "no phantom holds remain after the commit"
21252        );
21253    }
21254
21255    #[test]
21256    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
21257        let dir = tempfile::tempdir().unwrap();
21258        let db = Arc::new(Database::create(dir.path()).unwrap());
21259        db.create_table("parent", parent_schema()).unwrap();
21260        db.create_table("child", child_schema()).unwrap();
21261        let mut seed = db.begin();
21262        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21263        seed.commit().unwrap();
21264
21265        let entered = Arc::new(std::sync::Barrier::new(2));
21266        let resume = Arc::new(std::sync::Barrier::new(2));
21267        db.__set_fk_lock_hook({
21268            let entered = Arc::clone(&entered);
21269            let resume = Arc::clone(&resume);
21270            move || {
21271                entered.wait();
21272                resume.wait();
21273            }
21274        });
21275
21276        let mut txn_a = db.begin();
21277        txn_a
21278            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
21279            .unwrap();
21280        let a_id = txn_a.txn_id();
21281        let (a_tx, a_rx) = std::sync::mpsc::channel();
21282        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
21283        std::thread::scope(|scope| {
21284            scope.spawn(|| {
21285                a_tx.send(txn_a.commit()).unwrap();
21286            });
21287            entered.wait();
21288            // A is parked mid-validation: schema barrier held Shared.
21289            assert!(
21290                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
21291                "DML holds the schema barrier Shared for its commit"
21292            );
21293            let db = Arc::clone(&db);
21294            scope.spawn(move || {
21295                ddl_tx.send(db.drop_table("parent")).unwrap();
21296            });
21297            std::thread::sleep(std::time::Duration::from_millis(100));
21298            assert!(
21299                ddl_rx.try_recv().is_err(),
21300                "DDL must wait on the Exclusive schema barrier while DML is in flight"
21301            );
21302            resume.wait();
21303            // A now finishes and the waiting DDL proceeds. A's commit may
21304            // legitimately lose the publish race against the DDL's security
21305            // version advance — the designed "security policy changed during
21306            // write" outcome — or win it; the barrier guarantees only that the
21307            // DDL could not proceed before A released it.
21308            let a_result = a_rx.recv().unwrap();
21309            match &a_result {
21310                Ok(_) => {}
21311                Err(MongrelError::Conflict(message)) => {
21312                    assert!(
21313                        message.contains("security policy changed during write"),
21314                        "unexpected commit conflict: {message}"
21315                    );
21316                }
21317                other => panic!("unexpected commit outcome: {other:?}"),
21318            }
21319            assert!(ddl_rx.recv().unwrap().is_ok());
21320        });
21321        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
21322    }
21323
21324    #[test]
21325    fn auto_increment_sequence_barrier_held_until_commit() {
21326        let dir = tempfile::tempdir().unwrap();
21327        let db = Database::create(dir.path()).unwrap();
21328        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21329        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21330        let entered = Arc::new(std::sync::Barrier::new(2));
21331        let resume = Arc::new(std::sync::Barrier::new(2));
21332        let parked = Arc::new(AtomicBool::new(false));
21333        db.__set_catalog_commit_hook({
21334            let entered = Arc::clone(&entered);
21335            let resume = Arc::clone(&resume);
21336            let parked = Arc::clone(&parked);
21337            move || {
21338                if !parked.swap(true, Ordering::SeqCst) {
21339                    entered.wait();
21340                    resume.wait();
21341                }
21342            }
21343        });
21344
21345        let mut txn_a = db.begin();
21346        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
21347        let a_id = txn_a.txn_id();
21348        // The stage-time allocation already holds the barrier.
21349        assert!(
21350            db.lock_manager().holds(a_id, &barrier_key),
21351            "sequence allocation takes the barrier at stage time"
21352        );
21353        let (a_tx, a_rx) = std::sync::mpsc::channel();
21354        std::thread::scope(|scope| {
21355            scope.spawn(|| {
21356                a_tx.send(txn_a.commit()).unwrap();
21357            });
21358            entered.wait();
21359            assert!(
21360                db.lock_manager().holds(a_id, &barrier_key),
21361                "the barrier is held through the commit"
21362            );
21363            resume.wait();
21364            assert!(a_rx.recv().unwrap().is_ok());
21365        });
21366        assert!(
21367            !db.lock_manager().holds(a_id, &barrier_key),
21368            "the barrier releases when the commit ends"
21369        );
21370    }
21371
21372    #[test]
21373    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
21374        let dir = tempfile::tempdir().unwrap();
21375        let db = Database::create(dir.path()).unwrap();
21376        db.create_table("parent", parent_schema()).unwrap();
21377        db.create_table("child", child_schema()).unwrap();
21378        let mut seed = db.begin();
21379        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21380        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
21381        seed.commit().unwrap();
21382        let (rid1, rid2) = {
21383            let handle = db.table("parent").unwrap();
21384            let table = handle.lock();
21385            let rid = |pk: i64| {
21386                table
21387                    .lookup_pk(&Value::Int64(pk).encode_key())
21388                    .expect("seeded parent row")
21389            };
21390            (rid(1), rid(2))
21391        };
21392
21393        // The hook fires after every successful FK lock acquisition; park the
21394        // first two (A's and B's Exclusive delete-side claims) so both are
21395        // held before either transaction attempts its Shared insert-side
21396        // claim — a deterministic wait-for cycle.
21397        let rendezvous = Arc::new(std::sync::Barrier::new(2));
21398        let calls = Arc::new(AtomicUsize::new(0));
21399        db.__set_fk_lock_hook({
21400            let rendezvous = Arc::clone(&rendezvous);
21401            let calls = Arc::clone(&calls);
21402            move || {
21403                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
21404                    rendezvous.wait();
21405                }
21406            }
21407        });
21408
21409        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
21410        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
21411        let mut txn_a = db.begin();
21412        txn_a.delete("parent", rid1).unwrap();
21413        txn_a
21414            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
21415            .unwrap();
21416        let mut txn_b = db.begin();
21417        txn_b.delete("parent", rid2).unwrap();
21418        txn_b
21419            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
21420            .unwrap();
21421        let b_id = txn_b.txn_id();
21422
21423        let (a_tx, a_rx) = std::sync::mpsc::channel();
21424        let (b_tx, b_rx) = std::sync::mpsc::channel();
21425        std::thread::scope(|scope| {
21426            scope.spawn(|| {
21427                a_tx.send(txn_a.commit()).unwrap();
21428            });
21429            scope.spawn(|| {
21430                b_tx.send(txn_b.commit()).unwrap();
21431            });
21432            let a_result = a_rx.recv().unwrap();
21433            let b_result = b_rx.recv().unwrap();
21434            assert!(
21435                a_result.is_ok(),
21436                "the survivor commits once the victim releases: {a_result:?}"
21437            );
21438            match b_result {
21439                Err(MongrelError::Deadlock { victim, .. }) => {
21440                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
21441                }
21442                other => panic!("the victim must surface a deadlock, got {other:?}"),
21443            }
21444        });
21445        // No phantom holds survive the victim's release.
21446        let fk_key = |table: &str, pk: i64| {
21447            let table_id = db.table_id(table).unwrap();
21448            let mut key = b"fk:".to_vec();
21449            key.extend_from_slice(&Value::Int64(pk).encode_key());
21450            LockKey::key(table_id, key)
21451        };
21452        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
21453        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
21454    }
21455
21456    #[test]
21457    fn locks_release_after_commit_rollback_and_failed_commit() {
21458        let dir = tempfile::tempdir().unwrap();
21459        let db = Database::create(dir.path()).unwrap();
21460        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21461        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21462
21463        // Successful commit: the stage-time barrier releases with the commit.
21464        let mut txn = db.begin();
21465        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21466        let committed_id = txn.txn_id();
21467        txn.commit().unwrap();
21468        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
21469
21470        // Rollback: the stage-time barrier releases with the drop.
21471        let mut txn = db.begin();
21472        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21473        let rolled_back_id = txn.txn_id();
21474        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
21475        txn.rollback();
21476        assert!(
21477            !db.lock_manager().holds(rolled_back_id, &barrier_key),
21478            "rollback must not leave phantom holds"
21479        );
21480
21481        // Failed commit (declared-unique violation): the claims release with
21482        // the error.
21483        db.create_table("users", unique_schema()).unwrap();
21484        let users_id = db.table_id("users").unwrap();
21485        let mut seed = db.begin();
21486        seed.put(
21487            "users",
21488            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21489        )
21490        .unwrap();
21491        seed.commit().unwrap();
21492        let mut txn = db.begin();
21493        // A different primary key but the same declared-unique email: the
21494        // Phase B unique check rejects this commit.
21495        txn.put(
21496            "users",
21497            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
21498        )
21499        .unwrap();
21500        let failed_id = txn.txn_id();
21501        let result = txn.commit();
21502        assert!(matches!(result, Err(MongrelError::Conflict(_))));
21503        assert!(
21504            !db.lock_manager()
21505                .holds(failed_id, &pk_lock_key(users_id, 2)),
21506            "a failed commit must not leave phantom holds"
21507        );
21508    }
21509}
21510
21511#[cfg(test)]
21512mod lifecycle_tests {
21513    use super::*;
21514
21515    fn int_pk_schema() -> Schema {
21516        Schema {
21517            columns: vec![ColumnDef {
21518                id: 1,
21519                name: "id".into(),
21520                ty: TypeId::Int64,
21521                flags: crate::schema::ColumnFlags::empty()
21522                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21523                default_value: None,
21524                embedding_source: None,
21525            }],
21526            ..Schema::default()
21527        }
21528    }
21529
21530    #[test]
21531    fn poisoned_core_rejects_operations_with_typed_errors() {
21532        let dir = tempfile::tempdir().unwrap();
21533        let db = Database::create(dir.path()).unwrap();
21534        db.create_table("t", int_pk_schema()).unwrap();
21535        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
21536
21537        // Drive the exact two-state poison the fsync-error sites set
21538        // (write-path flag + lifecycle transition), without process-global
21539        // fault injection, which would leak into parallel tests. The fsync
21540        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
21541        db.poisoned.store(true, Ordering::Relaxed);
21542        db.lifecycle.poison();
21543        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
21544
21545        // Guarded operations without their own write-path poison check reject
21546        // at admission with the lifecycle Conflict...
21547        let error = db.gc().unwrap_err();
21548        assert!(
21549            matches!(error, MongrelError::Conflict(_)),
21550            "gc must reject on a poisoned core: {error:?}"
21551        );
21552        let error = db.compact().unwrap_err();
21553        assert!(
21554            matches!(error, MongrelError::Conflict(_)),
21555            "compact must reject on a poisoned core: {error:?}"
21556        );
21557        assert!(db.operation_guard().is_err());
21558        // ...while paths that already checked the write-path flag keep their
21559        // legacy error.
21560        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
21561        assert!(
21562            error.to_string().contains("database poisoned"),
21563            "the legacy poison error still wins where it existed: {error:?}"
21564        );
21565        let mut txn = db.begin();
21566        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
21567        assert!(txn
21568            .commit()
21569            .unwrap_err()
21570            .to_string()
21571            .contains("database poisoned"));
21572    }
21573
21574    #[test]
21575    fn shutdown_waits_for_operation_guards_to_drain() {
21576        let dir = tempfile::tempdir().unwrap();
21577        let db = Arc::new(Database::create(dir.path()).unwrap());
21578        db.create_table("t", int_pk_schema()).unwrap();
21579        // The guard holds the lifecycle's Arc, not the database's, so the
21580        // exclusive-owner shutdown can proceed to its drain step below.
21581        let guard = db.operation_guard().unwrap();
21582        let (started_tx, started_rx) = std::sync::mpsc::channel();
21583        let (done_tx, done_rx) = std::sync::mpsc::channel();
21584        let shutdown_thread = std::thread::spawn(move || {
21585            started_tx.send(()).unwrap();
21586            let result = db.shutdown();
21587            let _ = done_tx.send(result);
21588        });
21589        started_rx.recv().unwrap();
21590        std::thread::sleep(std::time::Duration::from_millis(100));
21591        assert!(
21592            done_rx.try_recv().is_err(),
21593            "shutdown must wait for the outstanding guard to drain"
21594        );
21595        drop(guard);
21596        shutdown_thread.join().unwrap();
21597        assert!(
21598            done_rx.recv().unwrap().is_ok(),
21599            "shutdown completes once the guard drops"
21600        );
21601    }
21602}
21603
21604#[cfg(test)]
21605mod commit_ts_ledger_tests {
21606    use super::*;
21607    use crate::memtable::Row;
21608
21609    fn int_pk_schema() -> Schema {
21610        Schema {
21611            columns: vec![ColumnDef {
21612                id: 1,
21613                name: "id".into(),
21614                ty: TypeId::Int64,
21615                flags: crate::schema::ColumnFlags::empty()
21616                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21617                default_value: None,
21618                embedding_source: None,
21619            }],
21620            ..Schema::default()
21621        }
21622    }
21623
21624    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
21625        let mut txn = db.begin();
21626        let handle = txn.state_handle();
21627        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
21628        let epoch = txn.commit().unwrap();
21629        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
21630            panic!("expected Committed, got {:?}", handle.state());
21631        };
21632        (epoch, receipt.commit_ts)
21633    }
21634
21635    #[test]
21636    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
21637        let dir = tempfile::tempdir().unwrap();
21638        let db = Database::create(dir.path()).unwrap();
21639        db.create_table("t", int_pk_schema()).unwrap();
21640
21641        let (epoch, commit_ts) = commit_one(&db);
21642        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
21643        // An epoch no commit sealed misses (callers fall back).
21644        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
21645    }
21646
21647    #[test]
21648    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
21649        let dir = tempfile::tempdir().unwrap();
21650        let (epoch, commit_ts) = {
21651            let db = Database::create(dir.path()).unwrap();
21652            db.create_table("t", int_pk_schema()).unwrap();
21653            commit_one(&db)
21654        };
21655
21656        let db = Database::open(dir.path()).unwrap();
21657        let reconstructed = db
21658            .commit_ts_for_epoch(epoch)
21659            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
21660        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
21661        // The ledger byte format stores micros only (spec §8.1): the logical
21662        // counter and tiebreaker reconstruct as 0.
21663        assert_eq!(reconstructed.logical, 0);
21664        assert_eq!(reconstructed.node_tiebreaker, 0);
21665    }
21666
21667    #[test]
21668    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
21669        use crate::wal::Op;
21670        let records = vec![
21671            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
21672            crate::wal::Record::new(
21673                Epoch(2),
21674                7,
21675                Op::TxnCommit {
21676                    epoch: 41,
21677                    added_runs: vec![],
21678                },
21679            ),
21680            // No CommitTimestamp for txn 8: not reconstructible.
21681            crate::wal::Record::new(
21682                Epoch(3),
21683                8,
21684                Op::TxnCommit {
21685                    epoch: 42,
21686                    added_runs: vec![],
21687                },
21688            ),
21689            // Timestamp without a commit marker: aborted, not reconstructible.
21690            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
21691        ];
21692        let ledger = commit_ts_ledger_from_recovery(&records);
21693        assert_eq!(ledger.len(), 1);
21694        assert_eq!(
21695            ledger.get(&41),
21696            Some(&mongreldb_types::hlc::HlcTimestamp {
21697                physical_micros: 1,
21698                logical: 0,
21699                node_tiebreaker: 0,
21700            })
21701        );
21702    }
21703
21704    #[test]
21705    fn new_writes_always_have_some_commit_ts() {
21706        let dir = tempfile::tempdir().unwrap();
21707        let db = Database::create(dir.path()).unwrap();
21708        db.create_table("t", int_pk_schema()).unwrap();
21709        let mut txn = db.begin();
21710        let state = txn.state_handle();
21711        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
21712        let epoch = txn.commit().unwrap();
21713        let crate::txn::TransactionState::Committed(receipt) = state.state() else {
21714            panic!("expected Committed receipt");
21715        };
21716        let handle = db.table("t").unwrap();
21717        let table = handle.lock();
21718        // Product snapshots are HLC-pinned; epoch-only Snapshot::at hides
21719        // HLC-stamped versions by design (no dual authority).
21720        let (snap, _g) = db.snapshot();
21721        let rows = table.visible_rows(snap).expect("visible rows");
21722        assert_eq!(
21723            rows.len(),
21724            1,
21725            "committed put must be visible under HLC snapshot"
21726        );
21727        assert_eq!(rows[0].commit_ts, Some(receipt.commit_ts));
21728        assert_eq!(db.commit_ts_for_epoch(epoch), Some(receipt.commit_ts));
21729    }
21730
21731    #[test]
21732    fn same_transaction_identical_hlc_on_apply() {
21733        use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21734
21735        let dir = tempfile::tempdir().unwrap();
21736        let db = Database::create_cluster_replica(
21737            dir.path(),
21738            ClusterId::from_bytes([1; 16]),
21739            NodeId::from_bytes([2; 16]),
21740            DatabaseId::from_bytes([3; 16]),
21741        )
21742        .unwrap();
21743        db.apply_replicated_catalog_command(&crate::catalog_cmds::CatalogCommandRecord {
21744            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21745            catalog_version: 1,
21746            command: crate::catalog_cmds::CatalogCommand::CreateTable {
21747                name: "t".into(),
21748                schema: int_pk_schema(),
21749                created_epoch: 1,
21750            },
21751        })
21752        .unwrap();
21753        let table_id = db.table_id("t").unwrap();
21754        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
21755            physical_micros: 42_000,
21756            logical: 3,
21757            node_tiebreaker: 9,
21758        };
21759        // Same decision HLC applied twice (two participants / two apply
21760        // calls) must stamp identical commit_ts on every row version.
21761        let staged = vec![StagedTxnWrite::Put {
21762            table_id,
21763            rows: bincode::serialize(&vec![
21764                Row::new(crate::RowId(1), Epoch(0)).with_column(1, Value::Int64(1))
21765            ])
21766            .unwrap(),
21767        }
21768        .encode()
21769        .unwrap()];
21770        assert!(db
21771            .apply_committed_transaction(1 << 63, commit_ts, &staged)
21772            .unwrap());
21773        let staged2 = vec![StagedTxnWrite::Put {
21774            table_id,
21775            rows: bincode::serialize(&vec![
21776                Row::new(crate::RowId(2), Epoch(0)).with_column(1, Value::Int64(2))
21777            ])
21778            .unwrap(),
21779        }
21780        .encode()
21781        .unwrap()];
21782        assert!(db
21783            .apply_committed_transaction((1 << 63) + 1, commit_ts, &staged2)
21784            .unwrap());
21785        let handle = db.table("t").unwrap();
21786        let table = handle.lock();
21787        let row1 = table
21788            .get(crate::RowId(1), Snapshot::unbounded())
21789            .expect("applied row 1");
21790        let row2 = table
21791            .get(crate::RowId(2), Snapshot::unbounded())
21792            .expect("applied row 2");
21793        // The durable WAL `Put` payload does not carry `Row::commit_ts`
21794        // (0.63.1 bincode layout); rows are restamped from the txn's
21795        // `Op::CommitTimestamp` ledger record, which carries physical micros
21796        // only. Both applies must observe that identical recovery form.
21797        let recovery_form = mongreldb_types::hlc::HlcTimestamp {
21798            physical_micros: commit_ts.physical_micros,
21799            logical: 0,
21800            node_tiebreaker: 0,
21801        };
21802        assert_eq!(row1.commit_ts, Some(recovery_form));
21803        assert_eq!(row2.commit_ts, Some(recovery_form));
21804        assert_eq!(row1.commit_ts, row2.commit_ts);
21805        drop(table);
21806        // Latest applied epoch's ledger entry matches the shared decision HLC
21807        // physical component (logical/tiebreaker may be zero on recovery form).
21808        let epoch = db.visible_epoch();
21809        assert_eq!(
21810            db.commit_ts_for_epoch(epoch).map(|ts| ts.physical_micros),
21811            Some(commit_ts.physical_micros)
21812        );
21813    }
21814
21815    #[test]
21816    fn snapshot_hlc_hides_later_commit_ts_even_if_epoch_higher() {
21817        use mongreldb_types::hlc::HlcTimestamp;
21818        let early = HlcTimestamp {
21819            physical_micros: 100,
21820            logical: 0,
21821            node_tiebreaker: 1,
21822        };
21823        let late = HlcTimestamp {
21824            physical_micros: 200,
21825            logical: 0,
21826            node_tiebreaker: 1,
21827        };
21828        // Snapshot at early HLC with a high epoch budget still hides a later HLC.
21829        let snap = Snapshot::at_hlc(Epoch(99), early);
21830        assert!(!snap.observes_version(Epoch(1), Some(late)));
21831        assert!(snap.observes_version(Epoch(1), Some(early)));
21832        // Live Database snapshots are HLC-pinned.
21833        let dir = tempfile::tempdir().unwrap();
21834        let db = Database::create(dir.path()).unwrap();
21835        let (snap, _g) = db.snapshot();
21836        assert_ne!(
21837            snap.commit_ts,
21838            HlcTimestamp::ZERO,
21839            "Database::snapshot must pin live HLC via at_hlc"
21840        );
21841    }
21842
21843    #[test]
21844    fn hlc_stamped_row_visible_at_hlc_snapshot_not_epoch_only() {
21845        let dir = tempfile::tempdir().unwrap();
21846        let db = Database::create(dir.path()).unwrap();
21847        db.create_table("t", int_pk_schema()).unwrap();
21848        let (_epoch, commit_ts) = commit_one(&db);
21849        assert_ne!(commit_ts, mongreldb_types::hlc::HlcTimestamp::ZERO);
21850
21851        let handle = db.table("t").unwrap();
21852        let table = handle.lock();
21853        // (a) HLC-stamped row visible at an HLC-pinned snapshot.
21854        let hlc_snap = Snapshot::at_hlc(Epoch(u64::MAX), commit_ts);
21855        let rows = table.visible_rows(hlc_snap).expect("visible");
21856        assert_eq!(rows.len(), 1);
21857        assert_eq!(rows[0].commit_ts, Some(commit_ts));
21858        assert!(table.get(rows[0].row_id, hlc_snap).is_some());
21859
21860        // (b) Epoch-only snapshot still sees HLC-stamped rows by epoch (dual-model).
21861        let legacy = Snapshot::at(Epoch(u64::MAX));
21862        assert!(!legacy.uses_hlc_authority());
21863        assert_eq!(
21864            table.visible_rows(legacy).expect("visible").len(),
21865            1,
21866            "epoch pin sees HLC-stamped rows by epoch during dual-model migration"
21867        );
21868        assert!(table.get(rows[0].row_id, legacy).is_some());
21869    }
21870
21871    #[test]
21872    fn hlc_gc_floor_reports_named_sources() {
21873        let dir = tempfile::tempdir().unwrap();
21874        let db = Database::create(dir.path()).unwrap();
21875        db.create_table("t", int_pk_schema()).unwrap();
21876        let (epoch, commit_ts) = commit_one(&db);
21877
21878        // No pins: every HLC source is ZERO.
21879        let empty = db.hlc_gc_floor();
21880        assert_eq!(empty.floor(), mongreldb_types::hlc::HlcTimestamp::ZERO);
21881        assert_eq!(empty.sources().len(), 6);
21882
21883        // Pin via product snapshot (transaction source, epoch-backed).
21884        let (_snap, guard) = db.snapshot();
21885        let with_pin = db.hlc_gc_floor();
21886        // Projection succeeds only when the ledger has a stamp for the pin epoch.
21887        let projected = db.commit_ts_for_epoch(epoch);
21888        if let Some(ts) = projected {
21889            // Snapshot pins the *visible* watermark, which should match the commit.
21890            assert_eq!(with_pin.transaction_snapshot, ts);
21891            assert_eq!(with_pin.floor(), ts);
21892            assert_eq!(ts.physical_micros, commit_ts.physical_micros);
21893        } else {
21894            assert_eq!(
21895                with_pin.transaction_snapshot,
21896                mongreldb_types::hlc::HlcTimestamp::ZERO
21897            );
21898        }
21899        drop(guard);
21900    }
21901}
21902
21903#[cfg(test)]
21904mod stage2e_storage_mode_tests {
21905    use super::*;
21906    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21907    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
21908    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21909
21910    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
21911        (
21912            ClusterId::from_bytes([seed; 16]),
21913            NodeId::from_bytes([seed + 1; 16]),
21914            DatabaseId::from_bytes([seed + 2; 16]),
21915        )
21916    }
21917
21918    fn marker(root: &Path) -> Option<StorageMode> {
21919        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
21920        crate::storage_mode::read(&durable).unwrap()
21921    }
21922
21923    fn simple_schema() -> Schema {
21924        Schema {
21925            columns: vec![ColumnDef {
21926                id: 1,
21927                name: "id".into(),
21928                ty: TypeId::Int64,
21929                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21930                default_value: None,
21931                embedding_source: None,
21932            }],
21933            ..Schema::default()
21934        }
21935    }
21936
21937    #[test]
21938    fn standalone_create_writes_marker_and_reopens() {
21939        let dir = tempfile::tempdir().unwrap();
21940        let root = dir.path().join("db");
21941        let db = Database::create(&root).unwrap();
21942        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21943        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
21944        drop(db);
21945        let db = Database::open(&root).unwrap();
21946        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21947        drop(db);
21948    }
21949
21950    #[test]
21951    fn legacy_database_without_marker_opens_and_gains_marker() {
21952        let dir = tempfile::tempdir().unwrap();
21953        let root = dir.path().join("db");
21954        let db = Database::create(&root).unwrap();
21955        drop(db);
21956        // Simulate a pre-marker database.
21957        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
21958        assert_eq!(marker(&root), None);
21959        let db = Database::open(&root).unwrap();
21960        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21961        drop(db);
21962    }
21963
21964    #[test]
21965    fn server_owned_standalone_opens_embedded() {
21966        let dir = tempfile::tempdir().unwrap();
21967        let root = dir.path().join("db");
21968        let db = Database::create(&root).unwrap();
21969        drop(db);
21970        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
21971        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
21972        let db = Database::open(&root).unwrap();
21973        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
21974        drop(db);
21975    }
21976
21977    #[test]
21978    fn cluster_replica_is_rejected_by_normal_opens() {
21979        let dir = tempfile::tempdir().unwrap();
21980        let root = dir.path().join("db");
21981        let (cluster_id, node_id, database_id) = identity(10);
21982        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21983        assert_eq!(
21984            marker(&root),
21985            Some(StorageMode::ClusterReplica {
21986                cluster_id,
21987                node_id,
21988                database_id,
21989            })
21990        );
21991        drop(db);
21992
21993        let error = Database::open(&root).unwrap_err();
21994        let message = error.to_string();
21995        assert!(
21996            matches!(error, MongrelError::InvalidArgument(_)),
21997            "unexpected error: {message}"
21998        );
21999        assert!(message.contains("cluster node runtime"), "{message}");
22000        assert!(message.contains(&cluster_id.to_hex()), "{message}");
22001        assert!(message.contains(&node_id.to_hex()), "{message}");
22002        assert!(message.contains(&database_id.to_hex()), "{message}");
22003
22004        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
22005        assert!(error.to_string().contains("cluster node runtime"));
22006        // The rejected opens never disturbed the marker.
22007        assert_eq!(
22008            marker(&root),
22009            Some(StorageMode::ClusterReplica {
22010                cluster_id,
22011                node_id,
22012                database_id,
22013            })
22014        );
22015    }
22016
22017    #[test]
22018    fn offline_validation_opens_cluster_replica_read_only() {
22019        let dir = tempfile::tempdir().unwrap();
22020        let root = dir.path().join("db");
22021        let (cluster_id, node_id, database_id) = identity(20);
22022        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
22023        drop(db);
22024
22025        let options = OpenOptions::default().with_offline_validation(true);
22026        let db = Database::open_with_options(&root, options).unwrap();
22027        assert!(db.is_read_only_replica());
22028        let error = db.create_table("t", simple_schema()).unwrap_err();
22029        assert!(matches!(error, MongrelError::ReadOnlyReplica));
22030        drop(db);
22031        // Offline validation leaves the marker exactly as found.
22032        assert_eq!(
22033            marker(&root),
22034            Some(StorageMode::ClusterReplica {
22035                cluster_id,
22036                node_id,
22037                database_id,
22038            })
22039        );
22040    }
22041
22042    #[test]
22043    fn cluster_runtime_open_requires_exact_identity() {
22044        let dir = tempfile::tempdir().unwrap();
22045        let root = dir.path().join("db");
22046        let (cluster_id, node_id, database_id) = identity(30);
22047        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
22048        drop(db);
22049
22050        // A non-ClusterReplica expectation is a caller error.
22051        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
22052        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22053        // Wrong database identity fails closed.
22054        let wrong = StorageMode::ClusterReplica {
22055            cluster_id,
22056            node_id,
22057            database_id: DatabaseId::from_bytes([99; 16]),
22058        };
22059        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
22060        assert!(error.to_string().contains("identity mismatch"), "{error}");
22061        // A legacy database without a marker is not a cluster replica.
22062        let legacy = dir.path().join("legacy");
22063        let legacy_db = Database::create(&legacy).unwrap();
22064        drop(legacy_db);
22065        let expected = StorageMode::ClusterReplica {
22066            cluster_id,
22067            node_id,
22068            database_id,
22069        };
22070        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
22071        assert!(error.to_string().contains("identity mismatch"), "{error}");
22072
22073        // The matching identity opens; user writes are rejected (writes
22074        // arrive through the replicated apply path only).
22075        let db = Database::open_cluster_replica(&root, &expected).unwrap();
22076        assert!(db.is_read_only_replica());
22077        let error = db.create_table("t", simple_schema()).unwrap_err();
22078        assert!(matches!(error, MongrelError::ReadOnlyReplica));
22079        drop(db);
22080    }
22081}
22082
22083#[cfg(test)]
22084mod stage2e_replicated_apply_tests {
22085    use super::*;
22086    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
22087    use crate::memtable::{Row, Value};
22088    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
22089    use crate::wal::{Op, Record};
22090    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
22091    use std::sync::Arc;
22092
22093    fn ids() -> (ClusterId, NodeId, DatabaseId) {
22094        (
22095            ClusterId::from_bytes([1; 16]),
22096            NodeId::from_bytes([2; 16]),
22097            DatabaseId::from_bytes([3; 16]),
22098        )
22099    }
22100
22101    fn expected_mode() -> crate::storage_mode::StorageMode {
22102        let (cluster_id, node_id, database_id) = ids();
22103        crate::storage_mode::StorageMode::ClusterReplica {
22104            cluster_id,
22105            node_id,
22106            database_id,
22107        }
22108    }
22109
22110    fn simple_schema() -> Schema {
22111        Schema {
22112            columns: vec![ColumnDef {
22113                id: 1,
22114                name: "id".into(),
22115                ty: TypeId::Int64,
22116                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22117                default_value: None,
22118                embedding_source: None,
22119            }],
22120            ..Schema::default()
22121        }
22122    }
22123
22124    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22125        CatalogCommandRecord {
22126            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22127            catalog_version,
22128            command: CatalogCommand::CreateTable {
22129                name: name.to_string(),
22130                schema: simple_schema(),
22131                created_epoch: 1,
22132            },
22133        }
22134    }
22135
22136    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22137        let rows: Vec<Row> = values
22138            .iter()
22139            .map(|value| {
22140                // Distinct row ids per value so batches never overwrite each
22141                // other's MVCC versions.
22142                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22143                    .with_column(1, Value::Int64(*value))
22144            })
22145            .collect();
22146        vec![
22147            Record::new(
22148                Epoch(0),
22149                txn_id,
22150                Op::Put {
22151                    table_id,
22152                    rows: bincode::serialize(&rows).unwrap(),
22153                },
22154            ),
22155            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22156            Record::new(
22157                Epoch(0),
22158                txn_id,
22159                Op::TxnCommit {
22160                    epoch,
22161                    added_runs: Vec::new(),
22162                },
22163            ),
22164        ]
22165    }
22166
22167    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22168        let handle = db.table(table).unwrap();
22169        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22170        let snap = db.visible_snapshot();
22171        let rows = handle.lock().visible_rows(snap).unwrap();
22172        let mut values: Vec<i64> = rows
22173            .iter()
22174            .map(|row| match row.columns.get(&1) {
22175                Some(Value::Int64(value)) => *value,
22176                other => panic!("unexpected column: {other:?}"),
22177            })
22178            .collect();
22179        values.sort_unstable();
22180        values
22181    }
22182
22183    #[test]
22184    fn catalog_command_mounts_table_and_replays_as_noop() {
22185        let dir = tempfile::tempdir().unwrap();
22186        let (cluster_id, node_id, database_id) = ids();
22187        let db =
22188            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22189
22190        let record = create_table_record("items", 1);
22191        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22192        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
22193        assert_eq!(db.table_names(), vec!["items".to_string()]);
22194        assert_eq!(db.catalog_version(), 1);
22195
22196        // Idempotent replay of the same record.
22197        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22198        assert!(matches!(delta, CatalogDelta::NoOp));
22199        assert_eq!(db.table_names().len(), 1);
22200        drop(db);
22201
22202        // The command was checkpointed: the table survives reopen.
22203        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22204        assert_eq!(db.table_names(), vec!["items".to_string()]);
22205        assert_eq!(db.catalog_version(), 1);
22206    }
22207
22208    #[test]
22209    fn records_apply_rows_and_skip_replays_across_restart() {
22210        let dir = tempfile::tempdir().unwrap();
22211        let (cluster_id, node_id, database_id) = ids();
22212        let db =
22213            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22214        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22215            .unwrap();
22216
22217        let records = put_records(1, 0, 2, &[10, 20, 30]);
22218        assert!(db.apply_replicated_records(&records).unwrap());
22219        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22220        assert_eq!(db.visible_epoch(), Epoch(2));
22221
22222        // Crash-window redelivery of the same committed transaction is a
22223        // side-effect-free replay.
22224        assert!(!db.apply_replicated_records(&records).unwrap());
22225        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22226
22227        // A later transaction at a higher epoch still applies.
22228        let later = put_records(2, 0, 3, &[40]);
22229        assert!(db.apply_replicated_records(&later).unwrap());
22230        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22231        let db = Arc::new(db);
22232        db.shutdown().unwrap();
22233
22234        // Restart: the local WAL replays the applied rows, and the state
22235        // machine's redelivery is recognized as a replay — no double-apply.
22236        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22237        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22238        assert!(!db.apply_replicated_records(&later).unwrap());
22239        assert!(!db.apply_replicated_records(&records).unwrap());
22240        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22241    }
22242
22243    #[test]
22244    fn spilled_run_commits_fail_closed_this_wave() {
22245        let dir = tempfile::tempdir().unwrap();
22246        let (cluster_id, node_id, database_id) = ids();
22247        let db =
22248            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22249        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22250            .unwrap();
22251        let mut records = put_records(1, 0, 2, &[10]);
22252        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22253            panic!("put_records ends in TxnCommit");
22254        };
22255        added_runs.push(crate::wal::AddedRun {
22256            table_id: 0,
22257            run_id: 7,
22258            row_count: 1,
22259            level: 0,
22260            min_row_id: 1,
22261            max_row_id: 1,
22262            content_hash: [0; 32],
22263        });
22264        let error = db.apply_replicated_records(&records).unwrap_err();
22265        assert!(
22266            error.to_string().contains("spilled-run"),
22267            "unexpected error: {error}"
22268        );
22269        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22270    }
22271
22272    #[test]
22273    fn records_without_commit_marker_fail_closed() {
22274        let dir = tempfile::tempdir().unwrap();
22275        let (cluster_id, node_id, database_id) = ids();
22276        let db =
22277            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22278        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22279            .unwrap();
22280        let mut records = put_records(1, 0, 2, &[10]);
22281        records.pop(); // strip the TxnCommit
22282        let error = db.apply_replicated_records(&records).unwrap_err();
22283        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22284        assert!(db.apply_replicated_records(&[]).is_err());
22285        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22286    }
22287}
22288
22289#[cfg(test)]
22290mod stage2c_spill_translation_tests {
22291    use super::*;
22292    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
22293    use crate::memtable::{Row, Value};
22294    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
22295    use crate::wal::{Op, Record};
22296    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
22297
22298    fn simple_schema() -> Schema {
22299        Schema {
22300            columns: vec![ColumnDef {
22301                id: 1,
22302                name: "id".into(),
22303                ty: TypeId::Int64,
22304                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22305                default_value: None,
22306                embedding_source: None,
22307            }],
22308            ..Schema::default()
22309        }
22310    }
22311
22312    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22313        CatalogCommandRecord {
22314            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22315            catalog_version,
22316            command: CatalogCommand::CreateTable {
22317                name: name.to_string(),
22318                schema: simple_schema(),
22319                created_epoch: 1,
22320            },
22321        }
22322    }
22323
22324    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22325        let handle = db.table(table).unwrap();
22326        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22327        let snap = db.visible_snapshot();
22328        let rows = handle.lock().visible_rows(snap).unwrap();
22329        let mut values: Vec<i64> = rows
22330            .iter()
22331            .map(|row| match row.columns.get(&1) {
22332                Some(Value::Int64(value)) => *value,
22333                other => panic!("unexpected column: {other:?}"),
22334            })
22335            .collect();
22336        values.sort_unstable();
22337        values
22338    }
22339
22340    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22341        let rows: Vec<Row> = values
22342            .iter()
22343            .map(|value| {
22344                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22345                    .with_column(1, Value::Int64(*value))
22346            })
22347            .collect();
22348        vec![
22349            Record::new(
22350                Epoch(0),
22351                txn_id,
22352                Op::Put {
22353                    table_id,
22354                    rows: bincode::serialize(&rows).unwrap(),
22355                },
22356            ),
22357            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22358            Record::new(
22359                Epoch(0),
22360                txn_id,
22361                Op::TxnCommit {
22362                    epoch,
22363                    added_runs: Vec::new(),
22364                },
22365            ),
22366        ]
22367    }
22368
22369    fn added_run(
22370        table_id: u64,
22371        row_count: u64,
22372        min_row_id: u64,
22373        max_row_id: u64,
22374    ) -> crate::wal::AddedRun {
22375        crate::wal::AddedRun {
22376            table_id,
22377            run_id: 7,
22378            row_count,
22379            level: 0,
22380            min_row_id,
22381            max_row_id,
22382            content_hash: [0; 32],
22383        }
22384    }
22385
22386    /// Replays the shared WAL of `db` and returns every record of the one
22387    /// transaction whose commit marker links spilled runs.
22388    fn spilled_commit_records(db: &Database) -> Vec<Record> {
22389        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
22390        let txn_id = records
22391            .iter()
22392            .find_map(|record| match &record.op {
22393                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
22394                _ => None,
22395            })
22396            .expect("a spilled commit is present in the WAL");
22397        records
22398            .into_iter()
22399            .filter(|record| record.txn_id == txn_id)
22400            .collect()
22401    }
22402
22403    #[test]
22404    fn non_spilled_records_translate_byte_identical() {
22405        let records = put_records(1, 0, 2, &[10, 20, 30]);
22406        let translated = translate_records_for_replication(&records).unwrap();
22407        assert_eq!(
22408            bincode::serialize(&translated).unwrap(),
22409            bincode::serialize(&records).unwrap(),
22410            "a commit without spill links must pass through byte-identical"
22411        );
22412    }
22413
22414    #[test]
22415    fn translation_rejects_uncovered_or_malformed_spills() {
22416        // added_runs with no logical spill records at all: rejected.
22417        let mut records = put_records(1, 0, 2, &[10]);
22418        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22419            panic!("put_records ends in TxnCommit");
22420        };
22421        added_runs.push(added_run(0, 1, 10, 10));
22422        let error = translate_records_for_replication(&records).unwrap_err();
22423        assert!(
22424            error.to_string().contains("no logical row records"),
22425            "unexpected error: {error}"
22426        );
22427
22428        // Coverage present but short of the linked row count: rejected.
22429        let mut records = put_records(1, 0, 2, &[10]);
22430        let spilled: Vec<Row> = (0..3_u64)
22431            .map(|value| {
22432                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
22433            })
22434            .collect();
22435        records.insert(
22436            0,
22437            Record::new(
22438                Epoch(0),
22439                1,
22440                Op::SpilledRows {
22441                    table_id: 0,
22442                    rows: bincode::serialize(&spilled).unwrap(),
22443                },
22444            ),
22445        );
22446        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22447            panic!("put_records ends in TxnCommit");
22448        };
22449        added_runs.push(added_run(0, 4, 0, 3));
22450        let error = translate_records_for_replication(&records).unwrap_err();
22451        assert!(
22452            error.to_string().contains("cover 3 rows"),
22453            "unexpected error: {error}"
22454        );
22455
22456        // An undecodable spill payload: rejected at propose time, never at apply.
22457        let mut records = put_records(1, 0, 2, &[10]);
22458        records.insert(
22459            0,
22460            Record::new(
22461                Epoch(0),
22462                1,
22463                Op::SpilledRows {
22464                    table_id: 0,
22465                    rows: vec![0xFF, 0x01, 0x02],
22466                },
22467            ),
22468        );
22469        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22470            panic!("put_records ends in TxnCommit");
22471        };
22472        added_runs.push(added_run(0, 1, 0, 0));
22473        assert!(translate_records_for_replication(&records).is_err());
22474
22475        // Structural violations mirror the apply-side contract.
22476        assert!(translate_records_for_replication(&[]).is_err());
22477        let mut mixed = put_records(1, 0, 2, &[10]);
22478        mixed[0].txn_id = 99;
22479        assert!(translate_records_for_replication(&mixed).is_err());
22480        let mut no_commit = put_records(1, 0, 2, &[10]);
22481        no_commit.pop();
22482        assert!(translate_records_for_replication(&no_commit).is_err());
22483    }
22484
22485    #[test]
22486    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
22487        // A real standalone commit that spills (spec section 8.5).
22488        let leader_dir = tempfile::tempdir().unwrap();
22489        let leader = Database::create(leader_dir.path()).unwrap();
22490        leader.create_table("t", simple_schema()).unwrap();
22491        leader.set_spill_threshold(1);
22492        let table_id = leader.table_id("t").unwrap();
22493        let values: Vec<i64> = (0..60).collect();
22494        leader
22495            .transaction(|txn| {
22496                for value in &values {
22497                    txn.put("t", vec![(1, Value::Int64(*value))])?;
22498                }
22499                Ok(())
22500            })
22501            .unwrap();
22502        assert_eq!(visible_ids(&leader, "t"), values);
22503
22504        // The leader's own WAL keeps the spill shape: SpilledRows records
22505        // plus an added_runs commit marker.
22506        let records = spilled_commit_records(&leader);
22507        assert!(records
22508            .iter()
22509            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22510        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
22511            panic!("a commit sequence ends in TxnCommit");
22512        };
22513        assert!(!added_runs.is_empty());
22514        let commit_epoch = *epoch;
22515
22516        // Translation strips every run reference and keeps the rows as
22517        // logical puts; the input sequence is untouched.
22518        let translated = translate_records_for_replication(&records).unwrap();
22519        assert!(records
22520            .iter()
22521            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22522        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
22523            panic!("a commit sequence ends in TxnCommit");
22524        };
22525        assert!(!added_runs.is_empty(), "input records must be unchanged");
22526        assert_eq!(translated.len(), records.len());
22527        assert!(translated
22528            .iter()
22529            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
22530        assert!(translated
22531            .iter()
22532            .any(|record| matches!(record.op, Op::Put { .. })));
22533        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
22534            panic!("a commit sequence ends in TxnCommit");
22535        };
22536        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
22537        assert_eq!(*epoch, commit_epoch);
22538
22539        // The translated payload applies on a replica with identical rows.
22540        let replica_dir = tempfile::tempdir().unwrap();
22541        let replica = Database::create_cluster_replica(
22542            replica_dir.path(),
22543            ClusterId::from_bytes([1; 16]),
22544            NodeId::from_bytes([2; 16]),
22545            DatabaseId::from_bytes([3; 16]),
22546        )
22547        .unwrap();
22548        replica
22549            .apply_replicated_catalog_command(&create_table_record("t", 1))
22550            .unwrap();
22551        assert_eq!(replica.table_id("t").unwrap(), table_id);
22552        assert!(replica.apply_replicated_records(&translated).unwrap());
22553        assert_eq!(visible_ids(&replica, "t"), values);
22554        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
22555
22556        // Standalone behavior is unchanged: the leader still recovers its
22557        // spilled commit by linking the run file.
22558        drop(leader);
22559        let leader = Database::open(leader_dir.path()).unwrap();
22560        assert_eq!(visible_ids(&leader, "t"), values);
22561    }
22562
22563    #[test]
22564    fn staged_txn_writes_validate_and_apply() {
22565        let dir = tempfile::tempdir().unwrap();
22566        let db = Database::create_cluster_replica(
22567            dir.path(),
22568            ClusterId::from_bytes([1; 16]),
22569            NodeId::from_bytes([2; 16]),
22570            DatabaseId::from_bytes([3; 16]),
22571        )
22572        .unwrap();
22573        db.apply_replicated_catalog_command(&create_table_record("t", 1))
22574            .unwrap();
22575        let table_id = db.table_id("t").unwrap();
22576
22577        // Malformed payloads and unmounted tables are rejected at prepare.
22578        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
22579        let unknown_table = StagedTxnWrite::Put {
22580            table_id: 99,
22581            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
22582        }
22583        .encode()
22584        .unwrap();
22585        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
22586        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
22587            .iter()
22588            .map(|value| {
22589                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
22590                    .with_column(1, Value::Int64(*value))];
22591                StagedTxnWrite::Put {
22592                    table_id,
22593                    rows: bincode::serialize(&rows).unwrap(),
22594                }
22595                .encode()
22596                .unwrap()
22597            })
22598            .collect();
22599        db.validate_staged_txn_writes(&good).unwrap();
22600
22601        // A committed resolution applies the staged writes; a delete
22602        // resolution removes them; both are replay-safe.
22603        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
22604            physical_micros: 5_000,
22605            logical: 0,
22606            node_tiebreaker: 0,
22607        };
22608        assert!(db
22609            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
22610            .unwrap());
22611        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
22612        let delete = StagedTxnWrite::Delete {
22613            table_id,
22614            row_ids: vec![20],
22615        }
22616        .encode()
22617        .unwrap();
22618        assert!(db
22619            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
22620            .unwrap());
22621        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22622
22623        // Restart: the synthetic WAL transactions replay through the same
22624        // recovery path; the rows are durable.
22625        let db = Arc::new(db);
22626        db.shutdown().unwrap();
22627        let expected = crate::storage_mode::StorageMode::ClusterReplica {
22628            cluster_id: ClusterId::from_bytes([1; 16]),
22629            node_id: NodeId::from_bytes([2; 16]),
22630            database_id: DatabaseId::from_bytes([3; 16]),
22631        };
22632        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
22633        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22634    }
22635}