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    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
14054    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
14055    pub fn has_ttl_tables(&self) -> bool {
14056        self.tables
14057            .read()
14058            .values()
14059            .any(|table| table.lock().ttl().is_some())
14060    }
14061
14062    /// Resolve a live table id → mounted handle (used by the constraint
14063    /// validation pass and other id-qualified internal paths).
14064    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
14065        self.tables
14066            .read()
14067            .get(&id)
14068            .cloned()
14069            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
14070    }
14071
14072    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
14073    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
14074    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
14075    /// allocation; explicit values merely advance the counter (an
14076    /// order-insensitive `max` under the table lock) and must not serialize.
14077    pub(crate) fn table_auto_inc_would_allocate(
14078        &self,
14079        table_id: u64,
14080        cells: &[(u16, Value)],
14081    ) -> bool {
14082        let catalog = self.catalog.read();
14083        let Some(entry) = catalog
14084            .tables
14085            .iter()
14086            .find(|entry| entry.table_id == table_id)
14087        else {
14088            return false;
14089        };
14090        let Some(column) = entry.schema.auto_increment_column() else {
14091            return false;
14092        };
14093        matches!(
14094            cells.iter().find(|(id, _)| *id == column.id),
14095            None | Some((_, Value::Null))
14096        )
14097    }
14098
14099    /// Create a new table. The DDL is first logged to the shared WAL
14100    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
14101    /// BEFORE the in-memory catalog and table map are mutated; the catalog
14102    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
14103    /// that sees a stale catalog still recovers the table by replaying the Ddl.
14104    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
14105        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14106            return Err(MongrelError::InvalidArgument(format!(
14107                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
14108            )));
14109        }
14110        self.create_table_with_state(name, schema, TableState::Live)
14111    }
14112
14113    /// Create a durable but non-queryable CTAS build table.
14114    #[doc(hidden)]
14115    pub fn create_building_table(
14116        &self,
14117        build_name: &str,
14118        intended_name: &str,
14119        query_id: &str,
14120        schema: Schema,
14121    ) -> Result<u64> {
14122        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14123            || intended_name.is_empty()
14124            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14125            || query_id.is_empty()
14126        {
14127            return Err(MongrelError::InvalidArgument(
14128                "invalid CTAS building-table identity".into(),
14129            ));
14130        }
14131        self.create_table_with_state(
14132            build_name,
14133            schema,
14134            TableState::Building {
14135                intended_name: intended_name.to_string(),
14136                query_id: query_id.to_string(),
14137                created_at_unix_nanos: current_unix_nanos(),
14138                replaces_table_id: None,
14139            },
14140        )
14141    }
14142
14143    /// Create a hidden schema-rebuild table while the intended target remains
14144    /// live. Publication later validates that the same target is still live.
14145    #[doc(hidden)]
14146    pub fn create_rebuilding_table(
14147        &self,
14148        build_name: &str,
14149        intended_name: &str,
14150        query_id: &str,
14151        schema: Schema,
14152    ) -> Result<u64> {
14153        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14154            || intended_name.is_empty()
14155            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14156            || query_id.is_empty()
14157        {
14158            return Err(MongrelError::InvalidArgument(
14159                "invalid rebuilding-table identity".into(),
14160            ));
14161        }
14162        let replaces_table_id = self
14163            .catalog
14164            .read()
14165            .live(intended_name)
14166            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
14167            .table_id;
14168        self.create_table_with_state(
14169            build_name,
14170            schema,
14171            TableState::Building {
14172                intended_name: intended_name.to_string(),
14173                query_id: query_id.to_string(),
14174                created_at_unix_nanos: current_unix_nanos(),
14175                replaces_table_id: Some(replaces_table_id),
14176            },
14177        )
14178    }
14179
14180    fn create_table_with_state(
14181        &self,
14182        name: &str,
14183        schema: Schema,
14184        state: TableState,
14185    ) -> Result<u64> {
14186        use crate::wal::DdlOp;
14187        use std::sync::atomic::Ordering;
14188
14189        self.require(&crate::auth::Permission::Ddl)?;
14190        if self.poisoned.load(Ordering::Relaxed) {
14191            return Err(MongrelError::Other(
14192                "database poisoned by fsync error".into(),
14193            ));
14194        }
14195        // S1A-004: admit the DDL as one core operation.
14196        let _operation = self.admit_operation()?;
14197        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14198        let _g = self.ddl_lock.lock();
14199        let _security_write = self.security_write()?;
14200        self.require(&crate::auth::Permission::Ddl)?;
14201        {
14202            let cat = self.catalog.read();
14203            match &state {
14204                TableState::Live => {
14205                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
14206                        return Err(MongrelError::InvalidArgument(format!(
14207                            "table {name:?} already exists or is being built"
14208                        )));
14209                    }
14210                }
14211                TableState::Building {
14212                    intended_name,
14213                    replaces_table_id,
14214                    ..
14215                } => {
14216                    let target_matches = match replaces_table_id {
14217                        Some(table_id) => cat
14218                            .live(intended_name)
14219                            .is_some_and(|entry| entry.table_id == *table_id),
14220                        None => cat.live(intended_name).is_none(),
14221                    };
14222                    if !target_matches || cat.building_for(intended_name).is_some() {
14223                        return Err(MongrelError::InvalidArgument(format!(
14224                            "table {intended_name:?} changed or is already being built"
14225                        )));
14226                    }
14227                    if cat.building(name).is_some() {
14228                        return Err(MongrelError::InvalidArgument(format!(
14229                            "building table {name:?} already exists"
14230                        )));
14231                    }
14232                }
14233                TableState::Dropped { .. } => {
14234                    return Err(MongrelError::InvalidArgument(
14235                        "cannot create a dropped table".into(),
14236                    ));
14237                }
14238            }
14239        }
14240
14241        // Allocate id + epoch + txn id under the commit lock so the DDL commit
14242        // is serialized with data commits (in-order publish). Live creates
14243        // draw their table id from the routed catalog command below (S1F-001);
14244        // CTAS building-table creates keep the legacy direct allocation.
14245        let commit_lock = Arc::clone(&self.commit_lock);
14246        let _c = commit_lock.lock();
14247        let live_create = matches!(state, TableState::Live);
14248        let legacy_table_id = if live_create {
14249            None
14250        } else {
14251            let mut cat = self.catalog.write();
14252            let id = cat.next_table_id;
14253            cat.next_table_id = id
14254                .checked_add(1)
14255                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
14256            Some(id)
14257        };
14258        let epoch = self.epoch.bump_assigned();
14259        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14260        let txn_id = self.alloc_txn_id()?;
14261
14262        let mut schema = schema;
14263        if let Some(table_id) = legacy_table_id {
14264            // Stamp the schema_id with the unique table_id so every table in
14265            // the database has a distinct schema_id (caller-provided values
14266            // are ignored to prevent collisions).
14267            schema.schema_id = table_id;
14268            // Defense in depth: reject an invalid schema BEFORE any durable
14269            // side-effect. `Table::create_in` re-validates, but by then the
14270            // DDL has already been appended to the shared WAL; a failing
14271            // create_in would leave a dangling entry that `recover_ddl_from_wal`
14272            // replays without re-validating, corrupting the catalog on reopen.
14273            // Validating here keeps the WAL free of schemas that can never be
14274            // opened.
14275            schema.validate_auto_increment()?;
14276            schema.validate_defaults()?;
14277            schema.validate_ai()?;
14278            for index in &schema.indexes {
14279                index.validate_options()?;
14280            }
14281            for constraint in &schema.constraints.checks {
14282                constraint.expr.validate()?;
14283            }
14284        }
14285
14286        let mut next_catalog = self.catalog.read().clone();
14287        let (table_id, schema) = match legacy_table_id {
14288            Some(table_id) => {
14289                next_catalog.tables.push(CatalogEntry {
14290                    table_id,
14291                    name: name.to_string(),
14292                    schema: schema.clone(),
14293                    state: state.clone(),
14294                    created_epoch: epoch.0,
14295                });
14296                (table_id, schema)
14297            }
14298            None => {
14299                // S1F-001: the mutation is a versioned catalog command —
14300                // schema validation (the same five validators), table-id
14301                // allocation, and schema_id stamping all resolve inside it.
14302                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
14303                    name: name.to_string(),
14304                    schema,
14305                    created_epoch: epoch.0,
14306                };
14307                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
14308                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
14309                    return Err(MongrelError::Other(
14310                        "CreateTable resolved to an unexpected catalog delta".into(),
14311                    ));
14312                };
14313                (entry.table_id, entry.schema)
14314            }
14315        };
14316        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14317
14318        // Build the complete mounted table before its DDL can become durable.
14319        // Any failure removes the unpublished directory and abandons the epoch.
14320        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
14321        let canonical_tdir = self.root.join(&table_relative);
14322        let table_root = Arc::new(
14323            self.durable_root
14324                .create_directory_all_pinned(&table_relative)?,
14325        );
14326        let tdir = table_root.io_path()?;
14327        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
14328        let ctx = SharedCtx {
14329            root_guard: Some(table_root),
14330            epoch: Arc::clone(&self.epoch),
14331            page_cache: Arc::clone(&self.page_cache),
14332            decoded_cache: Arc::clone(&self.decoded_cache),
14333            snapshots: Arc::clone(&self.snapshots),
14334            kek: self.kek.clone(),
14335            commit_lock: Arc::clone(&self.commit_lock),
14336            shared: Some(crate::engine::SharedWalCtx {
14337                wal: Arc::clone(&self.shared_wal),
14338                group: Arc::clone(&self.group),
14339                poisoned: Arc::clone(&self.poisoned),
14340                txn_ids: Arc::clone(&self.next_txn_id),
14341                change_wake: self.change_wake.clone(),
14342                lifecycle: Arc::clone(&self.lifecycle),
14343                hlc: Arc::clone(&self.hlc),
14344            }),
14345            table_name: Some(name.to_string()),
14346            auth: self.table_auth_checker(),
14347            read_only: self.read_only,
14348        };
14349        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
14350
14351        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
14352        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
14353        let schema_json = DdlOp::encode_schema(&schema)?;
14354        let ddl = match &state {
14355            TableState::Live => DdlOp::CreateTable {
14356                table_id,
14357                name: name.to_string(),
14358                schema_json,
14359            },
14360            TableState::Building {
14361                intended_name,
14362                query_id,
14363                created_at_unix_nanos,
14364                replaces_table_id,
14365            } => match replaces_table_id {
14366                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
14367                    table_id,
14368                    build_name: name.to_string(),
14369                    intended_name: intended_name.clone(),
14370                    query_id: query_id.clone(),
14371                    created_at_unix_nanos: *created_at_unix_nanos,
14372                    replaces_table_id: *replaces_table_id,
14373                    schema_json,
14374                },
14375                None => DdlOp::CreateBuildingTable {
14376                    table_id,
14377                    build_name: name.to_string(),
14378                    intended_name: intended_name.clone(),
14379                    query_id: query_id.clone(),
14380                    created_at_unix_nanos: *created_at_unix_nanos,
14381                    schema_json,
14382                },
14383            },
14384            TableState::Dropped { .. } => {
14385                return Err(MongrelError::InvalidArgument(
14386                    "cannot create a table in dropped state".into(),
14387                ));
14388            }
14389        };
14390        let commit_seq = {
14391            let mut wal = self.shared_wal.lock();
14392            let append: Result<u64> = (|| {
14393                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14394                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14395                wal.append_commit(txn_id, epoch, &[])
14396            })();
14397            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14398        };
14399        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14400        pending_table_dir.disarm();
14401
14402        // Publish the mounted table and catalog in memory even if the catalog
14403        // checkpoint fails after the WAL commit.
14404        self.tables
14405            .write()
14406            .insert(table_id, TableHandle::new(table));
14407        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14408        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14409        Ok(table_id)
14410    }
14411
14412    /// Logically drop a table, logging the DDL through the shared WAL first.
14413    pub fn drop_table(&self, name: &str) -> Result<()> {
14414        self.drop_table_with_epoch(name).map(|_| ())
14415    }
14416
14417    /// Logically drop a table and return the exact publication epoch.
14418    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
14419        self.drop_table_with_state(name, false, None)
14420    }
14421
14422    pub fn drop_table_with_epoch_controlled<F>(
14423        &self,
14424        name: &str,
14425        mut before_commit: F,
14426    ) -> Result<Epoch>
14427    where
14428        F: FnMut() -> Result<()>,
14429    {
14430        self.drop_table_with_state(name, false, Some(&mut before_commit))
14431    }
14432
14433    /// Discard an unpublished CTAS build.
14434    #[doc(hidden)]
14435    pub fn discard_building_table(&self, name: &str) -> Result<()> {
14436        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14437            return Err(MongrelError::InvalidArgument(
14438                "not a CTAS building table".into(),
14439            ));
14440        }
14441        self.drop_table_with_state(name, true, None).map(|_| ())
14442    }
14443
14444    fn drop_table_with_state(
14445        &self,
14446        name: &str,
14447        building: bool,
14448        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14449    ) -> Result<Epoch> {
14450        use crate::wal::DdlOp;
14451        use std::sync::atomic::Ordering;
14452
14453        self.require(&crate::auth::Permission::Ddl)?;
14454        if self.poisoned.load(Ordering::Relaxed) {
14455            return Err(MongrelError::Other(
14456                "database poisoned by fsync error".into(),
14457            ));
14458        }
14459        // S1A-004: admit the DDL as one core operation.
14460        let _operation = self.admit_operation()?;
14461        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14462        let _g = self.ddl_lock.lock();
14463        let _security_write = self.security_write()?;
14464        self.require(&crate::auth::Permission::Ddl)?;
14465        let table_id = {
14466            let cat = self.catalog.read();
14467            if building {
14468                cat.building(name)
14469            } else {
14470                cat.live(name)
14471            }
14472            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
14473            .table_id
14474        };
14475
14476        let commit_lock = Arc::clone(&self.commit_lock);
14477        let _c = commit_lock.lock();
14478        let epoch = self.epoch.bump_assigned();
14479        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14480        let txn_id = self.alloc_txn_id()?;
14481        let mut next_catalog = self.catalog.read().clone();
14482        if building {
14483            // CTAS building-table discards stay on the legacy mutation: the
14484            // command model's `DropTable` resolves live tables only.
14485            let entry = next_catalog
14486                .tables
14487                .iter_mut()
14488                .find(|t| t.table_id == table_id)
14489                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14490            entry.state = TableState::Dropped { at_epoch: epoch.0 };
14491            next_catalog.triggers.retain(|trigger| {
14492                !matches!(
14493                    &trigger.trigger.target,
14494                    TriggerTarget::Table(target) if target == name
14495                )
14496            });
14497            next_catalog
14498                .materialized_views
14499                .retain(|definition| definition.name != name);
14500            next_catalog
14501                .security
14502                .rls_tables
14503                .retain(|table| table != name);
14504            next_catalog
14505                .security
14506                .policies
14507                .retain(|policy| policy.table != name);
14508            next_catalog
14509                .security
14510                .masks
14511                .retain(|mask| mask.table != name);
14512            for role in &mut next_catalog.roles {
14513                role.permissions
14514                    .retain(|permission| permission_table(permission) != Some(name));
14515            }
14516            advance_security_version(&mut next_catalog)?;
14517        } else {
14518            // S1F-001: a plain live-table drop is a versioned catalog command
14519            // (the delta mirrors the legacy mutation above, cascading
14520            // triggers, materialized views, and table-scoped security state).
14521            let command = crate::catalog_cmds::CatalogCommand::DropTable {
14522                name: name.to_string(),
14523                at_epoch: epoch.0,
14524            };
14525            self.apply_catalog_command_to(&mut next_catalog, command)?;
14526        }
14527        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14528        let commit_seq = {
14529            let mut wal = self.shared_wal.lock();
14530            if let Some(before_commit) = before_commit {
14531                before_commit()?;
14532            }
14533            let append: Result<u64> = (|| {
14534                wal.append(
14535                    txn_id,
14536                    table_id,
14537                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
14538                )?;
14539                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14540                wal.append_commit(txn_id, epoch, &[])
14541            })();
14542            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14543        };
14544        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14545
14546        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14547        self.tables.write().remove(&table_id);
14548        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14549        Ok(epoch)
14550    }
14551
14552    /// Rename a live table. `name` must exist and `new_name` must not collide
14553    /// with any live table; both checks run under `ddl_lock` so they are atomic
14554    /// with the rename and with concurrent `create_table` existence checks (no
14555    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
14556    /// side-effects. The rename is logged to the shared WAL as
14557    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
14558    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
14559    /// the in-memory object does not move — only the catalog name changes).
14560    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
14561        self.rename_table_with_epoch(name, new_name).map(|_| ())
14562    }
14563
14564    /// Rename a table and return its exact publication epoch.
14565    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
14566        self.rename_table_with_epoch_inner(name, new_name, None)
14567    }
14568
14569    pub fn rename_table_with_epoch_controlled<F>(
14570        &self,
14571        name: &str,
14572        new_name: &str,
14573        mut before_commit: F,
14574    ) -> Result<Epoch>
14575    where
14576        F: FnMut() -> Result<()>,
14577    {
14578        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
14579    }
14580
14581    fn rename_table_with_epoch_inner(
14582        &self,
14583        name: &str,
14584        new_name: &str,
14585        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14586    ) -> Result<Epoch> {
14587        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14588            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14589        {
14590            return Err(MongrelError::InvalidArgument(
14591                "the CTAS building-table namespace is reserved".into(),
14592            ));
14593        }
14594        self.rename_table_with_state(name, new_name, false, None, before_commit)
14595    }
14596
14597    /// Atomically publish a hidden CTAS build under its intended live name.
14598    #[doc(hidden)]
14599    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14600        self.publish_building_table_inner(build_name, new_name, None)
14601    }
14602
14603    #[doc(hidden)]
14604    pub fn publish_building_table_controlled<F>(
14605        &self,
14606        build_name: &str,
14607        new_name: &str,
14608        mut before_commit: F,
14609    ) -> Result<Epoch>
14610    where
14611        F: FnMut() -> Result<()>,
14612    {
14613        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
14614    }
14615
14616    fn publish_building_table_inner(
14617        &self,
14618        build_name: &str,
14619        new_name: &str,
14620        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14621    ) -> Result<Epoch> {
14622        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14623            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14624        {
14625            return Err(MongrelError::InvalidArgument(
14626                "invalid CTAS publish identity".into(),
14627            ));
14628        }
14629        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
14630    }
14631
14632    /// Atomically publish a hidden build and its materialized-view definition.
14633    #[doc(hidden)]
14634    pub fn publish_materialized_building_table(
14635        &self,
14636        build_name: &str,
14637        new_name: &str,
14638        definition: crate::catalog::MaterializedViewEntry,
14639    ) -> Result<Epoch> {
14640        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
14641    }
14642
14643    #[doc(hidden)]
14644    pub fn publish_materialized_building_table_controlled<F>(
14645        &self,
14646        build_name: &str,
14647        new_name: &str,
14648        definition: crate::catalog::MaterializedViewEntry,
14649        mut before_commit: F,
14650    ) -> Result<Epoch>
14651    where
14652        F: FnMut() -> Result<()>,
14653    {
14654        self.publish_materialized_building_table_inner(
14655            build_name,
14656            new_name,
14657            definition,
14658            Some(&mut before_commit),
14659        )
14660    }
14661
14662    fn publish_materialized_building_table_inner(
14663        &self,
14664        build_name: &str,
14665        new_name: &str,
14666        definition: crate::catalog::MaterializedViewEntry,
14667        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14668    ) -> Result<Epoch> {
14669        if definition.name != new_name || definition.query.trim().is_empty() {
14670            return Err(MongrelError::InvalidArgument(
14671                "invalid materialized-view publication".into(),
14672            ));
14673        }
14674        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
14675    }
14676
14677    /// Atomically replace a still-live table with its completed hidden rebuild.
14678    #[doc(hidden)]
14679    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14680        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
14681    }
14682
14683    #[doc(hidden)]
14684    pub fn publish_rebuilding_table_controlled<F>(
14685        &self,
14686        build_name: &str,
14687        new_name: &str,
14688        mut before_commit: F,
14689    ) -> Result<Epoch>
14690    where
14691        F: FnMut() -> Result<()>,
14692    {
14693        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
14694    }
14695
14696    /// Atomically replace a live materialized-view table and its definition.
14697    #[doc(hidden)]
14698    pub fn publish_materialized_rebuilding_table_controlled<F>(
14699        &self,
14700        build_name: &str,
14701        new_name: &str,
14702        definition: crate::catalog::MaterializedViewEntry,
14703        mut before_commit: F,
14704    ) -> Result<Epoch>
14705    where
14706        F: FnMut() -> Result<()>,
14707    {
14708        self.publish_rebuilding_table_inner(
14709            build_name,
14710            new_name,
14711            Some(definition),
14712            Some(&mut before_commit),
14713        )
14714    }
14715
14716    fn publish_rebuilding_table_inner(
14717        &self,
14718        build_name: &str,
14719        new_name: &str,
14720        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14721        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14722    ) -> Result<Epoch> {
14723        use crate::wal::DdlOp;
14724
14725        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14726            || new_name.is_empty()
14727            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14728        {
14729            return Err(MongrelError::InvalidArgument(
14730                "invalid rebuilding-table publish identity".into(),
14731            ));
14732        }
14733        if materialized_view.as_ref().is_some_and(|definition| {
14734            definition.name != new_name || definition.query.trim().is_empty()
14735        }) {
14736            return Err(MongrelError::InvalidArgument(
14737                "invalid materialized-view replacement".into(),
14738            ));
14739        }
14740        self.require(&crate::auth::Permission::Ddl)?;
14741        if self.poisoned.load(Ordering::Relaxed) {
14742            return Err(MongrelError::Other(
14743                "database poisoned by fsync error".into(),
14744            ));
14745        }
14746        // S1A-004: admit the DDL as one core operation.
14747        let _operation = self.admit_operation()?;
14748        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14749        let _ddl = self.ddl_lock.lock();
14750        let _security_write = self.security_write()?;
14751        let (table_id, replaced_table_id) = {
14752            let catalog = self.catalog.read();
14753            let build = catalog.building(build_name).ok_or_else(|| {
14754                MongrelError::NotFound(format!("building table {build_name:?} not found"))
14755            })?;
14756            let replaced_table_id = match &build.state {
14757                TableState::Building {
14758                    intended_name,
14759                    replaces_table_id: Some(replaced_table_id),
14760                    ..
14761                } if intended_name == new_name => *replaced_table_id,
14762                _ => {
14763                    return Err(MongrelError::InvalidArgument(format!(
14764                        "building table {build_name:?} is not a replacement for {new_name:?}"
14765                    )))
14766                }
14767            };
14768            if catalog
14769                .live(new_name)
14770                .is_none_or(|entry| entry.table_id != replaced_table_id)
14771            {
14772                return Err(MongrelError::Conflict(format!(
14773                    "table {new_name:?} changed while its replacement was built"
14774                )));
14775            }
14776            (build.table_id, replaced_table_id)
14777        };
14778
14779        let _commit = self.commit_lock.lock();
14780        let epoch = self.epoch.assigned().next();
14781        let txn_id = self.alloc_txn_id()?;
14782        let mut next_catalog = self.catalog.read().clone();
14783        apply_rebuilding_publish(
14784            &mut next_catalog,
14785            table_id,
14786            replaced_table_id,
14787            new_name,
14788            epoch.0,
14789        )?;
14790        if let Some(definition) = materialized_view.as_mut() {
14791            definition.last_refresh_epoch = epoch.0;
14792        }
14793        let materialized_view_json = materialized_view
14794            .as_ref()
14795            .map(DdlOp::encode_materialized_view)
14796            .transpose()?;
14797        if let Some(definition) = materialized_view {
14798            if let Some(existing) = next_catalog
14799                .materialized_views
14800                .iter_mut()
14801                .find(|existing| existing.name == definition.name)
14802            {
14803                *existing = definition;
14804            } else {
14805                next_catalog.materialized_views.push(definition);
14806            }
14807        }
14808        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14809        if let Some(before_commit) = before_commit {
14810            before_commit()?;
14811        }
14812        let assigned_epoch = self.epoch.bump_assigned();
14813        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
14814        if assigned_epoch != epoch {
14815            return Err(MongrelError::Conflict(
14816                "commit epoch changed while sequencer lock was held".into(),
14817            ));
14818        }
14819        let commit_seq = {
14820            let mut wal = self.shared_wal.lock();
14821            let append: Result<u64> = (|| {
14822                wal.append(
14823                    txn_id,
14824                    table_id,
14825                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
14826                        table_id,
14827                        replaced_table_id,
14828                        new_name: new_name.to_string(),
14829                    }),
14830                )?;
14831                if let Some(definition_json) = materialized_view_json {
14832                    wal.append(
14833                        txn_id,
14834                        table_id,
14835                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14836                            name: new_name.to_string(),
14837                            definition_json,
14838                        }),
14839                    )?;
14840                }
14841                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14842                wal.append_commit(txn_id, epoch, &[])
14843            })();
14844            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14845        };
14846        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14847
14848        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14849        self.tables.write().remove(&replaced_table_id);
14850        if let Some(table) = self.tables.read().get(&table_id) {
14851            table.lock().set_catalog_name(new_name.to_string());
14852        }
14853        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
14854        Ok(epoch)
14855    }
14856
14857    fn rename_table_with_state(
14858        &self,
14859        name: &str,
14860        new_name: &str,
14861        building: bool,
14862        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14863        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14864    ) -> Result<Epoch> {
14865        use crate::wal::DdlOp;
14866        use std::sync::atomic::Ordering;
14867
14868        self.require(&crate::auth::Permission::Ddl)?;
14869        if self.poisoned.load(Ordering::Relaxed) {
14870            return Err(MongrelError::Other(
14871                "database poisoned by fsync error".into(),
14872            ));
14873        }
14874
14875        // A no-op rename short-circuits before any locking, so it can never
14876        // trip the "target already exists" check (the source *is* that name).
14877        if name == new_name {
14878            return Ok(self.visible_epoch());
14879        }
14880        if new_name.is_empty() {
14881            return Err(MongrelError::InvalidArgument(
14882                "rename_table: new name must not be empty".into(),
14883            ));
14884        }
14885        // S1A-004: admit the DDL as one core operation.
14886        let _operation = self.admit_operation()?;
14887        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14888        let _g = self.ddl_lock.lock();
14889        let _security_write = self.security_write()?;
14890        self.require(&crate::auth::Permission::Ddl)?;
14891        let table_id = {
14892            let cat = self.catalog.read();
14893            let src = if building {
14894                cat.building(name)
14895            } else {
14896                cat.live(name)
14897            }
14898            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14899            if building
14900                && !matches!(
14901                    &src.state,
14902                    TableState::Building { intended_name, .. } if intended_name == new_name
14903                )
14904            {
14905                return Err(MongrelError::InvalidArgument(format!(
14906                    "building table {name:?} is not reserved for {new_name:?}"
14907                )));
14908            }
14909            // Target must be free. Checked under ddl_lock, which every other
14910            // DDL (create/rename/drop) also holds, so a concurrent operation
14911            // cannot claim `new_name` between this check and the catalog write.
14912            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
14913                return Err(MongrelError::InvalidArgument(format!(
14914                    "rename_table: a table named {new_name:?} already exists"
14915                )));
14916            }
14917            src.table_id
14918        };
14919
14920        let commit_lock = Arc::clone(&self.commit_lock);
14921        let _c = commit_lock.lock();
14922        let epoch = self.epoch.bump_assigned();
14923        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14924        let txn_id = self.alloc_txn_id()?;
14925        if let Some(definition) = materialized_view.as_mut() {
14926            definition.last_refresh_epoch = epoch.0;
14927        }
14928        let materialized_view_json = materialized_view
14929            .as_ref()
14930            .map(DdlOp::encode_materialized_view)
14931            .transpose()?;
14932        let mut next_catalog = self.catalog.read().clone();
14933        if building {
14934            // CTAS building-table publishes stay on the legacy mutation: the
14935            // command model's `RenameTable` resolves live tables only.
14936            let entry = next_catalog
14937                .tables
14938                .iter_mut()
14939                .find(|t| t.table_id == table_id)
14940                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14941            entry.name = new_name.to_string();
14942            entry.state = TableState::Live;
14943            for trigger in &mut next_catalog.triggers {
14944                if matches!(
14945                    &trigger.trigger.target,
14946                    TriggerTarget::Table(target) if target == name
14947                ) {
14948                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
14949                }
14950            }
14951            if let Some(definition) = next_catalog
14952                .materialized_views
14953                .iter_mut()
14954                .find(|definition| definition.name == name)
14955            {
14956                definition.name = new_name.to_string();
14957            }
14958            if let Some(definition) = materialized_view.take() {
14959                next_catalog.materialized_views.push(definition);
14960            }
14961            for table in &mut next_catalog.security.rls_tables {
14962                if table == name {
14963                    *table = new_name.to_string();
14964                }
14965            }
14966            for policy in &mut next_catalog.security.policies {
14967                if policy.table == name {
14968                    policy.table = new_name.to_string();
14969                }
14970            }
14971            for mask in &mut next_catalog.security.masks {
14972                if mask.table == name {
14973                    mask.table = new_name.to_string();
14974                }
14975            }
14976            for role in &mut next_catalog.roles {
14977                for permission in &mut role.permissions {
14978                    rename_permission_table(permission, name, new_name);
14979                }
14980            }
14981            advance_security_version(&mut next_catalog)?;
14982        } else {
14983            // S1F-001: a plain live-table rename is a versioned catalog
14984            // command (the delta mirrors the legacy mutation above, including
14985            // trigger retargets and table-scoped security state).
14986            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
14987                name: name.to_string(),
14988                new_name: new_name.to_string(),
14989                at_epoch: epoch.0,
14990            };
14991            self.apply_catalog_command_to(&mut next_catalog, command)?;
14992        }
14993        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14994        let ddl = if building {
14995            DdlOp::PublishBuildingTable {
14996                table_id,
14997                new_name: new_name.to_string(),
14998            }
14999        } else {
15000            DdlOp::RenameTable {
15001                table_id,
15002                new_name: new_name.to_string(),
15003            }
15004        };
15005        let commit_seq = {
15006            let mut wal = self.shared_wal.lock();
15007            if let Some(before_commit) = before_commit {
15008                before_commit()?;
15009            }
15010            let append: Result<u64> = (|| {
15011                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
15012                if let Some(definition_json) = materialized_view_json {
15013                    wal.append(
15014                        txn_id,
15015                        table_id,
15016                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
15017                            name: new_name.to_string(),
15018                            definition_json,
15019                        }),
15020                    )?;
15021                }
15022                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15023                wal.append_commit(txn_id, epoch, &[])
15024            })();
15025            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15026        };
15027        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15028
15029        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
15030        // The in-memory table object is keyed by table_id, not name, so it does
15031        // not move and live TableHandles remain valid.
15032        if let Some(table) = self.tables.read().get(&table_id) {
15033            table.lock().set_catalog_name(new_name.to_string());
15034        }
15035        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
15036        Ok(epoch)
15037    }
15038
15039    /// Add a column through the database catalog and shared WAL.
15040    ///
15041    /// This is the catalog-aware counterpart to [`Table::add_column`]. The
15042    /// mounted table schema, catalog image, and recovery record advance as one
15043    /// durable DDL commit.
15044    pub fn add_column(
15045        &self,
15046        table_name: &str,
15047        name: &str,
15048        ty: TypeId,
15049        flags: ColumnFlags,
15050        default_value: Option<crate::schema::DefaultExpr>,
15051    ) -> Result<ColumnDef> {
15052        self.add_column_with_id(table_name, name, ty, flags, default_value, None)
15053    }
15054
15055    /// Add a catalog-aware column with an optional caller-assigned stable id.
15056    pub fn add_column_with_id(
15057        &self,
15058        table_name: &str,
15059        name: &str,
15060        ty: TypeId,
15061        flags: ColumnFlags,
15062        default_value: Option<crate::schema::DefaultExpr>,
15063        requested_id: Option<u16>,
15064    ) -> Result<ColumnDef> {
15065        use crate::wal::DdlOp;
15066        use std::sync::atomic::Ordering;
15067
15068        self.require(&crate::auth::Permission::Ddl)?;
15069        if self.poisoned.load(Ordering::Relaxed) {
15070            return Err(MongrelError::Other(
15071                "database poisoned by fsync error".into(),
15072            ));
15073        }
15074        let _operation = self.admit_operation()?;
15075        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15076        let _ddl = self.ddl_lock.lock();
15077        let _security_write = self.security_write()?;
15078        self.require(&crate::auth::Permission::Ddl)?;
15079        let table_id = {
15080            let catalog = self.catalog.read();
15081            catalog
15082                .live(table_name)
15083                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15084                .table_id
15085        };
15086        let handle =
15087            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15088                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15089            })?;
15090        let durable_epoch = std::cell::Cell::new(None);
15091        let result: Result<ColumnDef> = (|| {
15092            let mut table = handle.lock();
15093            let (column, prepared_schema) =
15094                table.prepare_add_column(name, ty, flags, default_value, requested_id)?;
15095            let command = crate::catalog_cmds::CatalogCommand::AddColumn {
15096                table: table_name.to_string(),
15097                column: column.clone(),
15098            };
15099            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15100
15101            let commit_lock = Arc::clone(&self.commit_lock);
15102            let _commit = commit_lock.lock();
15103            let epoch = self.epoch.bump_assigned();
15104            let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15105            let txn_id = self.alloc_txn_id()?;
15106            let column_json = DdlOp::encode_column(&column)?;
15107            let mut next_catalog = self.catalog.read().clone();
15108            let catalog_entry_index = next_catalog
15109                .tables
15110                .iter()
15111                .position(|entry| entry.table_id == table_id)
15112                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15113            self.apply_catalog_command_to(&mut next_catalog, command)?;
15114            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15115            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15116            let commit_seq = {
15117                let mut wal = self.shared_wal.lock();
15118                let append: Result<u64> = (|| {
15119                    wal.append(
15120                        txn_id,
15121                        table_id,
15122                        crate::wal::Op::Ddl(DdlOp::AlterTable {
15123                            table_id,
15124                            column_json,
15125                        }),
15126                    )?;
15127                    append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15128                    wal.append_commit(txn_id, epoch, &[])
15129                })();
15130                append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15131            };
15132            let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15133            durable_epoch.set(Some(epoch));
15134
15135            table.apply_altered_schema_prepared(prepared_schema);
15136            let schema = table.schema().clone();
15137            let table_checkpoint = table.checkpoint_altered_schema();
15138            drop(table);
15139            next_catalog.tables[catalog_entry_index].schema = schema;
15140            let catalog_result =
15141                catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15142            *self.catalog.write() = next_catalog;
15143            self.publish_committed(&receipt, epoch)?;
15144            epoch_guard.disarm();
15145            if let Err(error) = table_checkpoint.and(catalog_result) {
15146                self.poisoned.store(true, Ordering::Relaxed);
15147                self.lifecycle.poison();
15148                return Err(MongrelError::DurableCommit {
15149                    epoch: epoch.0,
15150                    message: error.to_string(),
15151                });
15152            }
15153            Ok(column)
15154        })();
15155        result.map_err(|error| match (durable_epoch.get(), error) {
15156            (_, error @ MongrelError::DurableCommit { .. }) => error,
15157            (Some(epoch), error) => MongrelError::DurableCommit {
15158                epoch: epoch.0,
15159                message: error.to_string(),
15160            },
15161            (None, error) => error,
15162        })
15163    }
15164
15165    pub fn alter_column(
15166        &self,
15167        table_name: &str,
15168        column_name: &str,
15169        change: AlterColumn,
15170    ) -> Result<ColumnDef> {
15171        self.alter_column_with_epoch(table_name, column_name, change)
15172            .map(|(column, _)| column)
15173    }
15174
15175    pub fn alter_column_with_epoch(
15176        &self,
15177        table_name: &str,
15178        column_name: &str,
15179        change: AlterColumn,
15180    ) -> Result<(ColumnDef, Option<Epoch>)> {
15181        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
15182    }
15183
15184    /// Cooperatively prepare an ALTER and fence each durable commit separately.
15185    /// `after_commit(Some(epoch))` follows an exact durable outcome;
15186    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
15187    /// for every successful `before_commit` callback.
15188    pub fn alter_column_with_epoch_controlled<B, A>(
15189        &self,
15190        table_name: &str,
15191        column_name: &str,
15192        change: AlterColumn,
15193        control: &crate::ExecutionControl,
15194        mut before_commit: B,
15195        mut after_commit: A,
15196    ) -> Result<(ColumnDef, Option<Epoch>)>
15197    where
15198        B: FnMut() -> Result<()>,
15199        A: FnMut(Option<Epoch>) -> Result<()>,
15200    {
15201        self.alter_column_with_epoch_inner(
15202            table_name,
15203            column_name,
15204            change,
15205            Some(control),
15206            Some(&mut before_commit),
15207            Some(&mut after_commit),
15208        )
15209    }
15210
15211    #[allow(clippy::too_many_arguments)]
15212    fn alter_column_with_epoch_inner(
15213        &self,
15214        table_name: &str,
15215        column_name: &str,
15216        change: AlterColumn,
15217        control: Option<&crate::ExecutionControl>,
15218        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
15219        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
15220    ) -> Result<(ColumnDef, Option<Epoch>)> {
15221        use crate::wal::DdlOp;
15222        use std::sync::atomic::Ordering;
15223
15224        self.require(&crate::auth::Permission::Ddl)?;
15225        commit_prepare_checkpoint(control, 0)?;
15226        if self.poisoned.load(Ordering::Relaxed) {
15227            return Err(MongrelError::Other(
15228                "database poisoned by fsync error".into(),
15229            ));
15230        }
15231        // S1A-004: admit the DDL as one core operation.
15232        let _operation = self.admit_operation()?;
15233        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15234        let _g = self.ddl_lock.lock();
15235        let table_id = {
15236            let cat = self.catalog.read();
15237            cat.live(table_name)
15238                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15239                .table_id
15240        };
15241        let handle =
15242            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15243                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15244            })?;
15245
15246        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
15247        // declared default, backfill existing NULL/absent cells as one durable
15248        // transaction before logging the metadata change. A crash between the
15249        // two commits leaves a harmless nullable-but-filled column; retry is
15250        // idempotent because only remaining NULLs are touched.
15251        let backfill = {
15252            let table = handle.lock();
15253            let old = table
15254                .schema()
15255                .column(column_name)
15256                .cloned()
15257                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
15258            let next_flags = change.flags.unwrap_or(old.flags);
15259            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
15260                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
15261                && old.default_value.is_some()
15262            {
15263                let snapshot = self.snapshot_for_epoch(self.epoch.visible());
15264                let mut updates = Vec::new();
15265                let rows = match control {
15266                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
15267                    None => table.visible_rows(snapshot)?,
15268                };
15269                for (row_index, row) in rows.into_iter().enumerate() {
15270                    commit_prepare_checkpoint(control, row_index)?;
15271                    if row
15272                        .columns
15273                        .get(&old.id)
15274                        .is_some_and(|value| !matches!(value, Value::Null))
15275                    {
15276                        continue;
15277                    }
15278                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
15279                    table.apply_defaults(&mut cells)?;
15280                    updates.push((
15281                        table_id,
15282                        crate::txn::Staged::Update {
15283                            row_id: row.row_id,
15284                            new_row: cells,
15285                            changed_columns: vec![old.id],
15286                        },
15287                    ));
15288                }
15289                updates
15290            } else {
15291                Vec::new()
15292            }
15293        };
15294        let durable_epoch = std::cell::Cell::new(None);
15295        let backfill_epoch = if backfill.is_empty() {
15296            None
15297        } else {
15298            let (principal, catalog_bound) = self.transaction_principal_snapshot();
15299            let txn_id = self.alloc_txn_id()?;
15300            let mut entered_fence = false;
15301            let commit_result = match (control, before_commit.as_deref_mut()) {
15302                (Some(control), Some(before_commit)) => self
15303                    .commit_transaction_with_external_states_controlled(
15304                        txn_id,
15305                        self.epoch.visible(),
15306                        backfill,
15307                        Vec::new(),
15308                        Vec::new(),
15309                        principal.clone(),
15310                        catalog_bound,
15311                        None,
15312                        crate::txn::TxnCommitContext::internal(),
15313                        control,
15314                        &mut || {
15315                            before_commit()?;
15316                            entered_fence = true;
15317                            Ok(())
15318                        },
15319                    )
15320                    .map(|(epoch, _)| epoch),
15321                _ => self
15322                    .commit_transaction_with_external_states(
15323                        txn_id,
15324                        self.epoch.visible(),
15325                        backfill,
15326                        Vec::new(),
15327                        Vec::new(),
15328                        principal,
15329                        catalog_bound,
15330                        None,
15331                        crate::txn::TxnCommitContext::internal(),
15332                    )
15333                    .map(|(epoch, _)| epoch),
15334            };
15335            let commit_result = if entered_fence {
15336                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15337            } else {
15338                commit_result
15339            };
15340            match &commit_result {
15341                Ok(epoch) => durable_epoch.set(Some(*epoch)),
15342                Err(MongrelError::DurableCommit { epoch, .. }) => {
15343                    durable_epoch.set(Some(Epoch(*epoch)));
15344                }
15345                Err(_) => {}
15346            }
15347            Some(commit_result?)
15348        };
15349        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
15350            let _security_write = self.security_write()?;
15351            self.require(&crate::auth::Permission::Ddl)?;
15352            if self
15353                .catalog
15354                .read()
15355                .live(table_name)
15356                .is_none_or(|entry| entry.table_id != table_id)
15357            {
15358                return Err(MongrelError::Conflict(format!(
15359                    "table {table_name:?} changed during ALTER"
15360                )));
15361            }
15362            let mut table = handle.lock();
15363            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
15364            let renamed_column = (column.name != column_name).then(|| column.name.clone());
15365            let Some(prepared_schema) = prepared_schema else {
15366                return Ok((column, backfill_epoch));
15367            };
15368            // S1F-001: the schema mutation is a versioned catalog command. It
15369            // validates pure against the current catalog before an epoch is
15370            // consumed; the engine's post-apply schema (schema_id bump
15371            // included) is the resolved delta the wrapper publishes.
15372            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
15373                table: table_name.to_string(),
15374                column: column.clone(),
15375            };
15376            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15377
15378            let commit_lock = Arc::clone(&self.commit_lock);
15379            let _c = commit_lock.lock();
15380            let epoch = self.epoch.bump_assigned();
15381            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15382            let txn_id = self.alloc_txn_id()?;
15383            let column_json = DdlOp::encode_column(&column)?;
15384            let mut next_catalog = self.catalog.read().clone();
15385            let catalog_entry_index = next_catalog
15386                .tables
15387                .iter()
15388                .position(|entry| entry.table_id == table_id)
15389                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15390            if let Some(new_column_name) = &renamed_column {
15391                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
15392                    commit_prepare_checkpoint(control, trigger_index)?;
15393                    if matches!(
15394                        &trigger.trigger.target,
15395                        TriggerTarget::Table(target) if target == table_name
15396                    ) {
15397                        trigger.trigger = trigger.trigger.renamed_update_column(
15398                            column_name,
15399                            new_column_name.clone(),
15400                            epoch.0,
15401                        )?;
15402                    }
15403                }
15404                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
15405                    commit_prepare_checkpoint(control, role_index)?;
15406                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
15407                        commit_prepare_checkpoint(control, permission_index)?;
15408                        rename_permission_column(
15409                            permission,
15410                            table_name,
15411                            column_name,
15412                            new_column_name,
15413                        );
15414                    }
15415                }
15416                advance_security_version(&mut next_catalog)?;
15417            }
15418            // Record the versioned command (validating again against the
15419            // candidate), then install the engine-resolved schema image:
15420            // identical to the command's delta when the mounted table and the
15421            // catalog are in sync, and byte-for-byte what the legacy inline
15422            // mutation published either way.
15423            self.apply_catalog_command_to(&mut next_catalog, command)?;
15424            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15425            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15426            commit_prepare_checkpoint(control, 0)?;
15427            let mut entered_fence = false;
15428            if let Some(before_commit) = before_commit.as_deref_mut() {
15429                before_commit()?;
15430                entered_fence = true;
15431            }
15432            let commit_result: Result<Epoch> = (|| {
15433                let commit_seq = {
15434                    let mut wal = self.shared_wal.lock();
15435                    let append: Result<u64> = (|| {
15436                        wal.append(
15437                            txn_id,
15438                            table_id,
15439                            crate::wal::Op::Ddl(DdlOp::AlterTable {
15440                                table_id,
15441                                column_json,
15442                            }),
15443                        )?;
15444                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15445                        wal.append_commit(txn_id, epoch, &[])
15446                    })();
15447                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15448                };
15449                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15450                durable_epoch.set(Some(epoch));
15451
15452                table.apply_altered_schema_prepared(prepared_schema);
15453                let schema = table.schema().clone();
15454                let table_checkpoint = table.checkpoint_altered_schema();
15455                drop(table);
15456                next_catalog.tables[catalog_entry_index].schema = schema;
15457                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15458                let catalog_result =
15459                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15460                let security_version = next_catalog.security_version;
15461                *self.catalog.write() = next_catalog;
15462                if renamed_column.is_some() {
15463                    self.security_coordinator
15464                        .version
15465                        .store(security_version, Ordering::Release);
15466                }
15467                self.publish_committed(&receipt, epoch)?;
15468                _epoch_guard.disarm();
15469                if let Err(error) = table_checkpoint.and(catalog_result) {
15470                    self.poisoned.store(true, Ordering::Relaxed);
15471                    self.lifecycle.poison();
15472                    return Err(MongrelError::DurableCommit {
15473                        epoch: epoch.0,
15474                        message: error.to_string(),
15475                    });
15476                }
15477                Ok(epoch)
15478            })();
15479            let commit_result = if entered_fence {
15480                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15481            } else {
15482                commit_result
15483            };
15484            let epoch = commit_result?;
15485            Ok((column, Some(epoch)))
15486        })();
15487        result.map_err(|error| match (durable_epoch.get(), error) {
15488            (_, error @ MongrelError::DurableCommit { .. }) => error,
15489            (Some(epoch), error) => MongrelError::DurableCommit {
15490                epoch: epoch.0,
15491                message: error.to_string(),
15492            },
15493            (None, error) => error,
15494        })
15495    }
15496
15497    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
15498    /// replication. Duration is in nanoseconds.
15499    pub fn set_table_ttl(
15500        &self,
15501        table_name: &str,
15502        column_name: &str,
15503        duration_nanos: u64,
15504    ) -> Result<crate::manifest::TtlPolicy> {
15505        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
15506        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
15507    }
15508
15509    /// Set TTL metadata on a hidden build before it is published.
15510    #[doc(hidden)]
15511    pub fn set_building_table_ttl(
15512        &self,
15513        table_name: &str,
15514        column_name: &str,
15515        duration_nanos: u64,
15516    ) -> Result<crate::manifest::TtlPolicy> {
15517        let policy = self.replace_table_ttl_with_state(
15518            table_name,
15519            Some((column_name, duration_nanos)),
15520            true,
15521        )?;
15522        policy
15523            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
15524    }
15525
15526    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
15527        self.replace_table_ttl(table_name, None)?;
15528        Ok(())
15529    }
15530
15531    fn replace_table_ttl(
15532        &self,
15533        table_name: &str,
15534        requested: Option<(&str, u64)>,
15535    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15536        self.replace_table_ttl_with_state(table_name, requested, false)
15537    }
15538
15539    fn replace_table_ttl_with_state(
15540        &self,
15541        table_name: &str,
15542        requested: Option<(&str, u64)>,
15543        building: bool,
15544    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15545        use crate::wal::DdlOp;
15546        use std::sync::atomic::Ordering;
15547
15548        self.require(&crate::auth::Permission::Ddl)?;
15549        if self.poisoned.load(Ordering::Relaxed) {
15550            return Err(MongrelError::Other(
15551                "database poisoned by fsync error".into(),
15552            ));
15553        }
15554
15555        let _g = self.ddl_lock.lock();
15556        let _security_write = self.security_write()?;
15557        self.require(&crate::auth::Permission::Ddl)?;
15558        let table_id = {
15559            let cat = self.catalog.read();
15560            if building {
15561                cat.building(table_name)
15562            } else {
15563                cat.live(table_name)
15564            }
15565            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15566            .table_id
15567        };
15568        let handle =
15569            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15570                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15571            })?;
15572        let mut table = handle.lock();
15573        let policy = match requested {
15574            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
15575            None => None,
15576        };
15577        if table.ttl() == policy {
15578            return Ok(policy);
15579        }
15580
15581        let commit_lock = Arc::clone(&self.commit_lock);
15582        let _c = commit_lock.lock();
15583        let epoch = self.epoch.bump_assigned();
15584        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15585        let txn_id = self.alloc_txn_id()?;
15586        let policy_json = DdlOp::encode_ttl(policy)?;
15587        let mut next_catalog = self.catalog.read().clone();
15588        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15589        let commit_seq = {
15590            let mut wal = self.shared_wal.lock();
15591            let append: Result<u64> = (|| {
15592                wal.append(
15593                    txn_id,
15594                    table_id,
15595                    crate::wal::Op::Ddl(DdlOp::SetTtl {
15596                        table_id,
15597                        policy_json,
15598                    }),
15599                )?;
15600                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15601                wal.append_commit(txn_id, epoch, &[])
15602            })();
15603            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15604        };
15605        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15606
15607        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
15608        drop(table);
15609        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
15610            publish_error.get_or_insert(error);
15611        }
15612        self.finish_durable_publish(
15613            epoch,
15614            &mut _epoch_guard,
15615            &receipt,
15616            publish_error.map_or(Ok(()), Err),
15617        )?;
15618        Ok(policy)
15619    }
15620
15621    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
15622    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
15623    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
15624    ///
15625    /// Returns the number of items reclaimed.
15626    pub fn gc(&self) -> Result<usize> {
15627        let control = crate::ExecutionControl::new(None);
15628        self.gc_controlled(&control, || true)
15629    }
15630
15631    /// Discover reclaimable state cooperatively, then cross one publication
15632    /// boundary immediately before the first irreversible deletion.
15633    #[doc(hidden)]
15634    pub fn gc_controlled<F>(
15635        &self,
15636        control: &crate::ExecutionControl,
15637        before_publish: F,
15638    ) -> Result<usize>
15639    where
15640        F: FnOnce() -> bool,
15641    {
15642        self.gc_controlled_with_receipt(control, before_publish)
15643            .map(|(reclaimed, _)| reclaimed)
15644    }
15645
15646    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
15647    /// return that snapshot if an irreversible deletion was attempted.
15648    #[doc(hidden)]
15649    pub fn gc_controlled_with_receipt<F>(
15650        &self,
15651        control: &crate::ExecutionControl,
15652        before_publish: F,
15653    ) -> Result<(usize, Option<MaintenanceReceipt>)>
15654    where
15655        F: FnOnce() -> bool,
15656    {
15657        enum Candidate {
15658            Directory(PathBuf),
15659            File(PathBuf),
15660        }
15661
15662        self.require(&crate::auth::Permission::Ddl)?;
15663        // S1A-004: admit the maintenance pass as one core operation.
15664        let _operation = self.admit_operation()?;
15665        let _ddl = self.ddl_lock.lock();
15666        self.require(&crate::auth::Permission::Ddl)?;
15667        control.checkpoint()?;
15668        let maintenance_epoch = self.epoch.visible();
15669        let min_active = self.snapshots.min_active(maintenance_epoch).0;
15670        let mut candidates = Vec::new();
15671
15672        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
15673        let cat = self.catalog.read();
15674        for (entry_index, entry) in cat.tables.iter().enumerate() {
15675            if entry_index % 256 == 0 {
15676                control.checkpoint()?;
15677            }
15678            if let TableState::Dropped { at_epoch } = entry.state {
15679                if at_epoch <= min_active {
15680                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
15681                    if tdir.exists() {
15682                        candidates.push(Candidate::Directory(tdir));
15683                    }
15684                }
15685            }
15686        }
15687        drop(cat);
15688
15689        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
15690        // in-flight spill's dir (deleting it would lose the pending run and fail
15691        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
15692        // skip any id still registered in `active_spills`.
15693        let cat = self.catalog.read();
15694        for (entry_index, entry) in cat.tables.iter().enumerate() {
15695            if entry_index % 256 == 0 {
15696                control.checkpoint()?;
15697            }
15698            if !matches!(entry.state, TableState::Live) {
15699                continue;
15700            }
15701            let txn_dir = self
15702                .root
15703                .join(TABLES_DIR)
15704                .join(entry.table_id.to_string())
15705                .join("_txn");
15706            if !txn_dir.exists() {
15707                continue;
15708            }
15709            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
15710                if sub_index % 256 == 0 {
15711                    control.checkpoint()?;
15712                }
15713                let sub = sub?;
15714                let name = sub.file_name();
15715                let Some(name) = name.to_str() else { continue };
15716                // A non-numeric entry can't belong to a live txn — sweep it.
15717                let is_active = name
15718                    .parse::<u64>()
15719                    .map(|id| self.active_spills.is_active(id))
15720                    .unwrap_or(false);
15721                if is_active {
15722                    continue;
15723                }
15724                candidates.push(Candidate::Directory(sub.path()));
15725            }
15726        }
15727        drop(cat);
15728
15729        let external_names = {
15730            let cat = self.catalog.read();
15731            cat.external_tables
15732                .iter()
15733                .map(|entry| entry.name.clone())
15734                .collect::<std::collections::HashSet<_>>()
15735        };
15736        let vtab_dir = self.root.join(VTAB_DIR);
15737        if vtab_dir.exists() {
15738            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
15739                if entry_index % 256 == 0 {
15740                    control.checkpoint()?;
15741                }
15742                let entry = entry?;
15743                let name = entry.file_name();
15744                let Some(name) = name.to_str() else { continue };
15745                if external_names.contains(name) {
15746                    continue;
15747                }
15748                let path = entry.path();
15749                if path.is_dir() {
15750                    candidates.push(Candidate::Directory(path));
15751                } else {
15752                    candidates.push(Candidate::File(path));
15753                }
15754            }
15755        }
15756
15757        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
15758        // can still need (spec §6.4). Each table deletes its own retired files
15759        // gated on `min_active` and persists its manifest.
15760        let tables = self
15761            .tables
15762            .read()
15763            .iter()
15764            .map(|(table_id, handle)| (*table_id, handle.clone()))
15765            .collect::<Vec<_>>();
15766        let mut retiring = Vec::new();
15767        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
15768            if table_index % 256 == 0 {
15769                control.checkpoint()?;
15770            }
15771            let backup_pinned: HashSet<u128> = self
15772                .backup_pins
15773                .lock()
15774                .keys()
15775                .filter_map(|(pinned_table, run_id)| {
15776                    (*pinned_table == *table_id).then_some(*run_id)
15777                })
15778                .collect();
15779            if handle
15780                .lock()
15781                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
15782            {
15783                retiring.push((handle.clone(), backup_pinned));
15784            }
15785        }
15786
15787        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
15788        // segment on every reopen without truncating the prior ones, so rotated
15789        // segments accumulate. Once every live table's committed data is durable
15790        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
15791        // (non-active) segments are redundant for recovery and safe to delete —
15792        // an in-flight txn only ever appends to the active segment, which is
15793        // never deleted.
15794        let all_durable = self.active_spills.is_idle()
15795            && tables.iter().all(|(_, handle)| {
15796                let g = handle.lock();
15797                g.memtable_len() == 0 && g.mutable_run_len() == 0
15798            });
15799        let retain = self
15800            .replication_wal_retention_segments
15801            .load(std::sync::atomic::Ordering::Relaxed);
15802        let reap_wal = all_durable
15803            && self
15804                .shared_wal
15805                .lock()
15806                .has_gc_segments_retain_recent(retain)?;
15807
15808        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
15809            return Ok((0, None));
15810        }
15811        control.checkpoint()?;
15812        if !before_publish() {
15813            return Err(MongrelError::Cancelled);
15814        }
15815
15816        let mut reclaimed = 0;
15817        for candidate in candidates {
15818            match candidate {
15819                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
15820                Candidate::File(path) => std::fs::remove_file(path)?,
15821            }
15822            reclaimed += 1;
15823        }
15824        for (handle, backup_pinned) in retiring {
15825            reclaimed += handle
15826                .lock()
15827                .reap_retiring(Epoch(min_active), &backup_pinned)?;
15828        }
15829        if reap_wal {
15830            reclaimed += self
15831                .shared_wal
15832                .lock()
15833                .gc_segments_retain_recent(u64::MAX, retain)?;
15834        }
15835
15836        Ok((
15837            reclaimed,
15838            Some(MaintenanceReceipt {
15839                epoch: maintenance_epoch,
15840            }),
15841        ))
15842    }
15843
15844    /// Produce a deterministic-stable byte image of the database directory.
15845    ///
15846    /// After `checkpoint()`:
15847    ///   - All pending writes are flushed to sorted runs (no memtable data).
15848    ///   - Each table is compacted to a single sorted run (no run fragmentation).
15849    ///   - All non-active WAL segments are deleted (data is durable in runs).
15850    ///   - The active WAL segment is rotated to a fresh empty segment.
15851    ///   - Dropped-table directories are removed.
15852    ///   - All manifests + catalog are persisted.
15853    ///
15854    /// The resulting directory is byte-stable: `git add` captures a snapshot
15855    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
15856    /// no unbounded segment growth, no mutable-run spill files.
15857    ///
15858    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
15859    /// It does NOT clear the exclusive lock — the caller still owns the
15860    /// database handle.
15861    pub fn checkpoint(&self) -> Result<()> {
15862        self.checkpoint_controlled(|| Ok(()))
15863    }
15864
15865    /// Strict checkpoint with a deterministic test hook after every table is
15866    /// flushed/compacted but before WAL replacement.
15867    #[doc(hidden)]
15868    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
15869    where
15870        F: FnOnce() -> Result<()>,
15871    {
15872        self.require(&crate::auth::Permission::Ddl)?;
15873        // S1A-004: admit the checkpoint as one core operation.
15874        let _operation = self.admit_operation()?;
15875        // Block cross-table commits and DDL for the full operation. Locking all
15876        // mounted handles also excludes direct `Table` commits, which do not
15877        // enter the database replication barrier.
15878        let _replication = self.replication_barrier.write();
15879        let _ddl = self.ddl_lock.lock();
15880        let _security = self.security_coordinator.gate.read();
15881        self.require(&crate::auth::Permission::Ddl)?;
15882
15883        let mut handles = self
15884            .tables
15885            .read()
15886            .iter()
15887            .map(|(table_id, handle)| (*table_id, handle.clone()))
15888            .collect::<Vec<_>>();
15889        handles.sort_by_key(|(table_id, _)| *table_id);
15890        let mut tables = handles
15891            .iter()
15892            .map(|(table_id, handle)| (*table_id, handle.lock()))
15893            .collect::<Vec<_>>();
15894
15895        // Strict flush. Any error leaves the old WAL recovery source intact.
15896        for (_, table) in &mut tables {
15897            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
15898            {
15899                table.force_flush()?;
15900            }
15901        }
15902
15903        // Strict compaction. Checkpoint never reports a stable image after a
15904        // skipped failure.
15905        for (_, table) in &mut tables {
15906            if table.run_count() >= 2 || table.should_compact() {
15907                table.compact()?;
15908            }
15909        }
15910
15911        before_wal_reset()?;
15912
15913        // Reap table-local retired runs while every table remains quiesced.
15914        let maintenance_epoch = self.epoch.visible();
15915        let min_active = self.snapshots.min_active(maintenance_epoch);
15916        for (table_id, table) in &mut tables {
15917            let backup_pinned: HashSet<u128> = self
15918                .backup_pins
15919                .lock()
15920                .keys()
15921                .filter_map(|(pinned_table, run_id)| {
15922                    (*pinned_table == *table_id).then_some(*run_id)
15923                })
15924                .collect();
15925            table.reap_retiring(min_active, &backup_pinned)?;
15926        }
15927
15928        // Publish a fresh synced active WAL, then durably reap every older
15929        // segment. This point is reached only after every strict flush succeeds.
15930        self.shared_wal.lock().reset_after_checkpoint()?;
15931
15932        // Remove catalog-unreachable directories and stale transaction state.
15933        let catalog_snapshot = self.catalog.read().clone();
15934        for entry in &catalog_snapshot.tables {
15935            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
15936                crate::durable_file::remove_directory_all(
15937                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
15938                )?;
15939            }
15940            if !matches!(entry.state, TableState::Live) {
15941                continue;
15942            }
15943            let transaction_dir = self
15944                .root
15945                .join(TABLES_DIR)
15946                .join(entry.table_id.to_string())
15947                .join("_txn");
15948            if transaction_dir.is_dir() {
15949                for child in std::fs::read_dir(&transaction_dir)? {
15950                    let child = child?;
15951                    let active = child
15952                        .file_name()
15953                        .to_str()
15954                        .and_then(|name| name.parse::<u64>().ok())
15955                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
15956                    if !active {
15957                        crate::durable_file::remove_directory_all(&child.path())?;
15958                    }
15959                }
15960            }
15961        }
15962        let external_names = catalog_snapshot
15963            .external_tables
15964            .iter()
15965            .map(|entry| entry.name.as_str())
15966            .collect::<HashSet<_>>();
15967        let external_root = self.root.join(VTAB_DIR);
15968        if external_root.is_dir() {
15969            for entry in std::fs::read_dir(&external_root)? {
15970                let entry = entry?;
15971                let name = entry.file_name();
15972                if name
15973                    .to_str()
15974                    .is_some_and(|name| external_names.contains(name))
15975                {
15976                    continue;
15977                }
15978                if entry.file_type()?.is_dir() {
15979                    crate::durable_file::remove_directory_all(&entry.path())?;
15980                } else {
15981                    std::fs::remove_file(entry.path())?;
15982                    crate::durable_file::sync_directory(&external_root)?;
15983                }
15984            }
15985        }
15986
15987        // Final authoritative metadata checkpoint while all writers remain
15988        // excluded.
15989        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
15990        let visible = self.epoch.visible();
15991        for (_, table) in &tables {
15992            table.persist_manifest(visible)?;
15993        }
15994
15995        Ok(())
15996    }
15997    fn alloc_txn_id(&self) -> Result<u64> {
15998        self.ensure_owner_process()?;
15999        crate::txn::allocate_txn_id(&self.next_txn_id)
16000    }
16001
16002    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
16003    /// (or other multi-statement lock holds). Released via
16004    /// [`Self::release_txn_locks`].
16005    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
16006        self.alloc_txn_id()
16007    }
16008
16009    /// Set the per-table spill threshold (bytes). When a transaction's staged
16010    /// bytes for a single table exceed this, the rows are written as a
16011    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
16012    pub fn set_spill_threshold(&self, bytes: u64) {
16013        self.spill_threshold
16014            .store(bytes, std::sync::atomic::Ordering::Relaxed);
16015    }
16016
16017    /// Test-only: install a hook invoked after a transaction writes its spill
16018    /// runs but before the sequencer, so a test can race `gc()` against an
16019    /// in-flight spill. Not part of the stable API.
16020    #[doc(hidden)]
16021    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16022        *self.spill_hook.lock() = Some(Box::new(f));
16023    }
16024
16025    /// Test-only: install a hook invoked while a spilled commit holds the
16026    /// security read gate and before it appends to the WAL.
16027    #[doc(hidden)]
16028    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16029        *self.security_commit_hook.lock() = Some(Box::new(f));
16030    }
16031
16032    /// Test-only: install a hook after transaction preparation and before the
16033    /// commit sequencer validates catalog generations.
16034    #[doc(hidden)]
16035    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16036        *self.catalog_commit_hook.lock() = Some(Box::new(f));
16037    }
16038
16039    /// Test-only: pause an online backup after its consistent boundary is
16040    /// captured but before the pinned immutable runs are copied.
16041    #[doc(hidden)]
16042    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16043        *self.backup_hook.lock() = Some(Box::new(f));
16044    }
16045
16046    /// Test-only: invoked after each successful FK parent-protection lock
16047    /// acquisition during constraint validation, so tests can rendezvous two
16048    /// committing transactions into a deterministic wait-for cycle.
16049    #[doc(hidden)]
16050    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16051        *self.fk_lock_hook.lock() = Some(Arc::new(f));
16052    }
16053
16054    /// Test-only: pause WAL extraction before its final principal recheck.
16055    #[doc(hidden)]
16056    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16057        *self.replication_hook.lock() = Some(Box::new(f));
16058    }
16059
16060    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
16061    /// this stays well below the number of committed transactions when commits
16062    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
16063    #[doc(hidden)]
16064    pub fn __wal_group_sync_count(&self) -> u64 {
16065        self.shared_wal.lock().group_sync_count()
16066    }
16067
16068    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
16069    /// contract that an fsync error would trigger in production.
16070    #[doc(hidden)]
16071    pub fn __poison(&self) {
16072        self.poisoned
16073            .store(true, std::sync::atomic::Ordering::Relaxed);
16074    }
16075
16076    /// Verify multi-table integrity (spec §16). For every live table this:
16077    /// authenticates the manifest; opens each `RunRef`'s file through
16078    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
16079    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
16080    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
16081    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
16082    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
16083    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
16084    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
16085    ///
16086    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
16087    /// full body, so this is an integrity tool, not a hot path.
16088    pub fn check(&self) -> Vec<CheckIssue> {
16089        match self.check_inner(None) {
16090            Ok(issues) => issues,
16091            Err(error) => vec![CheckIssue {
16092                table_id: WAL_TABLE_ID,
16093                table_name: "shared WAL".into(),
16094                severity: "error".into(),
16095                description: error.to_string(),
16096            }],
16097        }
16098    }
16099
16100    /// Integrity check with cooperative cancellation between tables and runs.
16101    #[doc(hidden)]
16102    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
16103        self.check_inner(Some(control))
16104    }
16105
16106    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
16107        let mut issues = Vec::new();
16108        let io_root = self.durable_root.io_path()?;
16109        let cat = self.catalog.read();
16110        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
16111        for (table_index, entry) in cat.tables.iter().enumerate() {
16112            if table_index % 256 == 0 {
16113                if let Some(control) = control {
16114                    control.checkpoint()?;
16115                }
16116            }
16117            if !matches!(entry.state, TableState::Live) {
16118                continue;
16119            }
16120            let tdir = io_root.join(TABLES_DIR).join(entry.table_id.to_string());
16121            let mut err = |sev: &str, desc: String| {
16122                issues.push(CheckIssue {
16123                    table_id: entry.table_id,
16124                    table_name: entry.name.clone(),
16125                    severity: sev.into(),
16126                    description: desc,
16127                });
16128            };
16129            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
16130                Ok(m) => m,
16131                Err(e) => {
16132                    err("error", format!("manifest read failed: {e}"));
16133                    continue;
16134                }
16135            };
16136            if m.flushed_epoch > m.current_epoch {
16137                err(
16138                    "error",
16139                    format!(
16140                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
16141                        m.flushed_epoch, m.current_epoch
16142                    ),
16143                );
16144            }
16145
16146            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
16147            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
16148            for (run_index, rr) in m.runs.iter().enumerate() {
16149                if run_index % 256 == 0 {
16150                    if let Some(control) = control {
16151                        control.checkpoint()?;
16152                    }
16153                }
16154                referenced.insert(rr.run_id);
16155                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
16156                if !run_path.exists() {
16157                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
16158                    continue;
16159                }
16160                match crate::sorted_run::RunReader::open(
16161                    &run_path,
16162                    entry.schema.clone(),
16163                    self.kek.clone(),
16164                ) {
16165                    Ok(reader) => {
16166                        if reader.row_count() as u64 != rr.row_count {
16167                            err(
16168                                "error",
16169                                format!(
16170                                    "run r-{} row count mismatch: manifest {} vs run {}",
16171                                    rr.run_id,
16172                                    rr.row_count,
16173                                    reader.row_count()
16174                                ),
16175                            );
16176                        }
16177                    }
16178                    Err(e) => {
16179                        err(
16180                            "error",
16181                            format!("run r-{} integrity check failed: {e}", rr.run_id),
16182                        );
16183                    }
16184                }
16185            }
16186
16187            // Compaction-superseded runs awaiting retention-gated deletion are
16188            // tracked in `retiring`; their files are expected on disk, so they
16189            // are not orphans.
16190            for r in &m.retiring {
16191                referenced.insert(r.run_id);
16192            }
16193
16194            // Orphan `.sr` files present on disk but absent from the manifest.
16195            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
16196                for (entry_index, ent) in rd.flatten().enumerate() {
16197                    if entry_index % 256 == 0 {
16198                        if let Some(control) = control {
16199                            control.checkpoint()?;
16200                        }
16201                    }
16202                    let p = ent.path();
16203                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
16204                        continue;
16205                    }
16206                    let run_id = p
16207                        .file_stem()
16208                        .and_then(|s| s.to_str())
16209                        .and_then(|s| s.strip_prefix("r-"))
16210                        .and_then(|s| s.parse::<u128>().ok());
16211                    if let Some(id) = run_id {
16212                        if !referenced.contains(&id) {
16213                            err(
16214                                "warning",
16215                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
16216                            );
16217                        }
16218                    }
16219                }
16220            }
16221        }
16222
16223        let external_names = cat
16224            .external_tables
16225            .iter()
16226            .map(|entry| entry.name.clone())
16227            .collect::<std::collections::HashSet<_>>();
16228        let vtab_dir = io_root.join(VTAB_DIR);
16229        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
16230            for (entry_index, entry) in entries.flatten().enumerate() {
16231                if entry_index % 256 == 0 {
16232                    if let Some(control) = control {
16233                        control.checkpoint()?;
16234                    }
16235                }
16236                let name = entry.file_name();
16237                let Some(name) = name.to_str() else { continue };
16238                if !external_names.contains(name) {
16239                    issues.push(CheckIssue {
16240                        table_id: EXTERNAL_TABLE_ID,
16241                        table_name: name.to_string(),
16242                        severity: "warning".into(),
16243                        description: format!(
16244                            "orphan external table state entry {:?} not referenced by the catalog",
16245                            entry.path()
16246                        ),
16247                    });
16248                }
16249            }
16250        }
16251
16252        // WAL retention / integrity invariant (spec §16): every on-disk WAL
16253        // segment must open (header magic + version, and the frame cipher must
16254        // be derivable for an encrypted WAL). A segment that won't open is
16255        // corrupt or truncated and would break crash recovery. `table_id` is
16256        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
16257        // never confuses a WAL issue with a real table.
16258        if let Some(control) = control {
16259            control.checkpoint()?;
16260        }
16261        for (seg, msg) in self.shared_wal.lock().verify_segments() {
16262            issues.push(CheckIssue {
16263                table_id: WAL_TABLE_ID,
16264                table_name: "<wal>".into(),
16265                severity: "error".into(),
16266                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
16267            });
16268        }
16269        Ok(issues)
16270    }
16271
16272    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
16273    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
16274    /// unmounts them from the live table map so the DB still opens.
16275    pub fn doctor(&self) -> Result<Vec<u64>> {
16276        let control = crate::ExecutionControl::new(None);
16277        self.doctor_controlled(&control, || true)
16278    }
16279
16280    /// Check cancellably, then fence immediately before the first quarantine
16281    /// mutation. Returning `false` from `before_publish` leaves the database
16282    /// untouched.
16283    #[doc(hidden)]
16284    pub fn doctor_controlled<F>(
16285        &self,
16286        control: &crate::ExecutionControl,
16287        before_publish: F,
16288    ) -> Result<Vec<u64>>
16289    where
16290        F: FnOnce() -> bool,
16291    {
16292        self.doctor_controlled_with_receipt(control, before_publish)
16293            .map(|(quarantined, _)| quarantined)
16294    }
16295
16296    /// Check cancellably and return the exact catalog epoch used for a
16297    /// quarantine publication. No receipt is returned when nothing changes.
16298    #[doc(hidden)]
16299    pub fn doctor_controlled_with_receipt<F>(
16300        &self,
16301        control: &crate::ExecutionControl,
16302        before_publish: F,
16303    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
16304    where
16305        F: FnOnce() -> bool,
16306    {
16307        // Hold the DDL lock for the whole operation to prevent concurrent
16308        // create_table/drop_table from racing the catalog/dir mutation.
16309        let _ddl = self.ddl_lock.lock();
16310        let _security_write = self.security_write()?;
16311        let issues = self.check_inner(Some(control))?;
16312        // A corrupt WAL segment is reported as an error but is NOT a table
16313        // problem — quarantining an innocent table cannot fix it (and the first
16314        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
16315        // them disjoint). The admin must address WAL corruption manually.
16316        let bad_tables: std::collections::HashSet<u64> = issues
16317            .iter()
16318            .filter(|i| {
16319                i.severity == "error"
16320                    && i.table_id != WAL_TABLE_ID
16321                    && i.table_id != EXTERNAL_TABLE_ID
16322            })
16323            .map(|i| i.table_id)
16324            .collect();
16325        if bad_tables.is_empty() {
16326            return Ok((Vec::new(), None));
16327        }
16328        let _commit = self.commit_lock.lock();
16329        control.checkpoint()?;
16330        if !before_publish() {
16331            return Err(MongrelError::Cancelled);
16332        }
16333        let maintenance_epoch = self.epoch.bump_assigned();
16334        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
16335
16336        let qdir = self.root.join("_quarantine");
16337        crate::durable_file::create_directory(&qdir)?;
16338        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
16339        bad_tables.sort_unstable();
16340
16341        // Quiesce every mounted target before catalog publication. Existing
16342        // handle clones are marked unavailable in the publication callback so
16343        // they cannot append to the shared WAL after their catalog entry drops.
16344        let mut handles = self
16345            .tables
16346            .read()
16347            .iter()
16348            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
16349            .map(|(table_id, handle)| (*table_id, handle.clone()))
16350            .collect::<Vec<_>>();
16351        handles.sort_by_key(|(table_id, _)| *table_id);
16352        let mut table_guards = handles
16353            .iter()
16354            .map(|(table_id, handle)| (*table_id, handle.lock()))
16355            .collect::<Vec<_>>();
16356
16357        let mut next_catalog = self.catalog.read().clone();
16358        for table_id in &bad_tables {
16359            if let Some(entry) = next_catalog
16360                .tables
16361                .iter_mut()
16362                .find(|entry| entry.table_id == *table_id)
16363            {
16364                entry.state = TableState::Dropped {
16365                    at_epoch: maintenance_epoch.0,
16366                };
16367            }
16368        }
16369        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
16370
16371        let txn_id = self.alloc_txn_id()?;
16372        let commit_seq = {
16373            let mut wal = self.shared_wal.lock();
16374            let append: Result<u64> = (|| {
16375                for table_id in &bad_tables {
16376                    wal.append(
16377                        txn_id,
16378                        *table_id,
16379                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
16380                            table_id: *table_id,
16381                        }),
16382                    )?;
16383                }
16384                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
16385                wal.append_commit(txn_id, maintenance_epoch, &[])
16386            })();
16387            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
16388        };
16389        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
16390        for (_, table) in &mut table_guards {
16391            table.mark_unavailable_after_quarantine();
16392        }
16393        {
16394            let mut live_tables = self.tables.write();
16395            for table_id in &bad_tables {
16396                live_tables.remove(table_id);
16397            }
16398        }
16399        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
16400        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
16401
16402        // Release DOCTOR's own table guards and handle clones before moving
16403        // the directory. Windows refuses to rename files held open by the
16404        // final mounted Table instance.
16405        drop(table_guards);
16406        drop(handles);
16407
16408        // The catalog drop is durable. Directory placement is secondary but
16409        // still uses a write-through rename. A failure reports the known
16410        // catalog outcome and leaves a harmless orphan under `tables/`.
16411        for table_id in &bad_tables {
16412            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
16413            if source.exists() {
16414                let destination = qdir.join(table_id.to_string());
16415                if let Err(error) = crate::durable_file::rename(&source, &destination) {
16416                    return Err(MongrelError::DurableCommit {
16417                        epoch: maintenance_epoch.0,
16418                        message: format!(
16419                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
16420                        ),
16421                    });
16422                }
16423            }
16424        }
16425        Ok((
16426            bad_tables,
16427            Some(MaintenanceReceipt {
16428                epoch: maintenance_epoch,
16429            }),
16430        ))
16431    }
16432
16433    /// The DB-wide KEK (if encrypted).
16434    #[allow(dead_code)]
16435    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
16436        self.kek.as_ref()
16437    }
16438
16439    /// Shared epoch authority (used by the transaction layer in P2).
16440    #[allow(dead_code)]
16441    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
16442        &self.epoch
16443    }
16444
16445    /// Shared snapshot registry (used by GC in P3.6).
16446    #[allow(dead_code)]
16447    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
16448        &self.snapshots
16449    }
16450}
16451
16452fn external_state_dir(root: &Path, name: &str) -> PathBuf {
16453    root.join(VTAB_DIR).join(name)
16454}
16455
16456fn append_catalog_snapshot(
16457    wal: &mut crate::wal::SharedWal,
16458    txn_id: u64,
16459    catalog: &Catalog,
16460) -> Result<()> {
16461    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
16462    wal.append(
16463        txn_id,
16464        WAL_TABLE_ID,
16465        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
16466    )?;
16467    Ok(())
16468}
16469
16470fn filter_ignored_staging(
16471    staging: Vec<(u64, crate::txn::Staged)>,
16472    ignored_indices: &std::collections::BTreeSet<usize>,
16473) -> Vec<(u64, crate::txn::Staged)> {
16474    if ignored_indices.is_empty() {
16475        return staging;
16476    }
16477    staging
16478        .into_iter()
16479        .enumerate()
16480        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
16481        .collect()
16482}
16483
16484fn external_state_file(root: &Path, name: &str) -> PathBuf {
16485    external_state_dir(root, name).join("state.json")
16486}
16487
16488fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
16489    let path = external_state_file(root, name);
16490    match std::fs::read(path) {
16491        Ok(bytes) => Ok(bytes),
16492        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
16493        Err(e) => Err(e.into()),
16494    }
16495}
16496
16497fn current_external_state_bytes(
16498    root: &Path,
16499    external_states: &[(String, Vec<u8>)],
16500    name: &str,
16501) -> Result<Vec<u8>> {
16502    for (table, state) in external_states.iter().rev() {
16503        if table == name {
16504            return Ok(state.clone());
16505        }
16506    }
16507    read_external_state_file(root, name)
16508}
16509
16510fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
16511    let mut out = external_states;
16512    dedup_external_states_in_place(&mut out);
16513    out
16514}
16515
16516fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
16517    let mut seen = std::collections::HashSet::new();
16518    let mut out = Vec::with_capacity(external_states.len());
16519    for (name, state) in std::mem::take(external_states).into_iter().rev() {
16520        if seen.insert(name.clone()) {
16521            out.push((name, state));
16522        }
16523    }
16524    out.reverse();
16525    *external_states = out;
16526}
16527
16528fn prepare_external_state_file(
16529    root: &Path,
16530    name: &str,
16531    state: &[u8],
16532    txn_id: u64,
16533) -> Result<PathBuf> {
16534    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
16535    let dir = external_state_dir(root, name);
16536    crate::durable_file::create_directory(&dir)?;
16537    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
16538    {
16539        let mut file = std::fs::OpenOptions::new()
16540            .create_new(true)
16541            .write(true)
16542            .open(&pending)?;
16543        file.write_all(state)?;
16544        file.sync_all()?;
16545    }
16546    Ok(pending)
16547}
16548
16549fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
16550    let path = external_state_file(root, name);
16551    crate::durable_file::replace(pending, &path)?;
16552    Ok(())
16553}
16554
16555fn write_external_state_file(
16556    durable: &crate::durable_file::DurableRoot,
16557    name: &str,
16558    state: &[u8],
16559) -> Result<()> {
16560    let directory = Path::new(VTAB_DIR).join(name);
16561    durable.create_directory_all(&directory)?;
16562    durable.write_atomic(directory.join("state.json"), state)?;
16563    Ok(())
16564}
16565
16566fn validate_recovered_data_table(
16567    catalog: &Catalog,
16568    tables: &HashMap<u64, TableHandle>,
16569    table_id: u64,
16570    commit_epoch: u64,
16571    offset: u64,
16572) -> Result<bool> {
16573    let entry = catalog
16574        .tables
16575        .iter()
16576        .find(|entry| entry.table_id == table_id)
16577        .ok_or_else(|| MongrelError::CorruptWal {
16578            offset,
16579            reason: format!("committed record references unknown table {table_id}"),
16580        })?;
16581    if commit_epoch < entry.created_epoch {
16582        return Err(MongrelError::CorruptWal {
16583            offset,
16584            reason: format!(
16585                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16586                entry.created_epoch
16587            ),
16588        });
16589    }
16590    match entry.state {
16591        TableState::Dropped { at_epoch } => {
16592            // Abandoned hidden builds are marked dropped at the last durable
16593            // boundary during open, so their final build commit may equal the
16594            // cleanup epoch. Ordinary table drops consume a new epoch and must
16595            // remain strictly later than every data commit.
16596            let abandoned_build_boundary =
16597                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16598            if commit_epoch >= at_epoch && !abandoned_build_boundary {
16599                Err(MongrelError::CorruptWal {
16600                    offset,
16601                    reason: format!(
16602                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16603                    ),
16604                })
16605            } else {
16606                Ok(false)
16607            }
16608        }
16609        TableState::Live | TableState::Building { .. } => {
16610            if tables.contains_key(&table_id) {
16611                Ok(true)
16612            } else {
16613                Err(MongrelError::CorruptWal {
16614                    offset,
16615                    reason: format!("live table {table_id} has no mounted recovery handle"),
16616                })
16617            }
16618        }
16619    }
16620}
16621
16622type RecoveryTableStage = (
16623    Vec<crate::memtable::Row>,
16624    Vec<(crate::rowid::RowId, Epoch)>,
16625    Option<Epoch>,
16626    Epoch,
16627);
16628
16629#[derive(Clone)]
16630struct RecoveryValidationTable {
16631    schema: Schema,
16632    flushed_epoch: u64,
16633}
16634
16635fn validate_shared_wal_recovery_plan(
16636    durable_root: &crate::durable_file::DurableRoot,
16637    catalog: &Catalog,
16638    recovered_table_ids: &HashSet<u64>,
16639    reconciled_table_ids: &HashSet<u64>,
16640    meta_dek: Option<&[u8; META_DEK_LEN]>,
16641    kek: Option<Arc<crate::encryption::Kek>>,
16642    records: &[crate::wal::Record],
16643) -> Result<()> {
16644    use crate::wal::{DdlOp, Op};
16645
16646    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
16647    for entry in &catalog.tables {
16648        if !matches!(entry.state, TableState::Live) {
16649            continue;
16650        }
16651        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
16652        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
16653            Ok(manifest) => Some(manifest),
16654            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
16655            Err(error) => return Err(error),
16656        };
16657        let flushed_epoch = if let Some(manifest) = manifest {
16658            if manifest.table_id != entry.table_id {
16659                return Err(MongrelError::Conflict(format!(
16660                    "catalog table {} storage identity mismatch",
16661                    entry.table_id
16662                )));
16663            }
16664            if (manifest.schema_id != entry.schema.schema_id
16665                && !reconciled_table_ids.contains(&entry.table_id))
16666                || manifest.flushed_epoch > manifest.current_epoch
16667                || manifest.global_idx_epoch > manifest.current_epoch
16668                || manifest.next_row_id == u64::MAX
16669                || manifest.auto_inc_next < 0
16670                || manifest.auto_inc_next == i64::MAX
16671                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
16672            {
16673                return Err(MongrelError::InvalidArgument(format!(
16674                    "table {} manifest counters or schema identity are invalid",
16675                    entry.table_id
16676                )));
16677            }
16678            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
16679            crate::global_idx::read_durable_for(
16680                durable_root,
16681                &relative_dir,
16682                entry.table_id,
16683                &entry.schema,
16684                idx_dek.as_deref(),
16685            )?;
16686            let mut run_ids = HashSet::new();
16687            let mut maximum_row_id = None::<u64>;
16688            for run in &manifest.runs {
16689                if run.run_id >= u64::MAX as u128
16690                    || run.epoch_created > manifest.current_epoch
16691                    || !run_ids.insert(run.run_id)
16692                {
16693                    return Err(MongrelError::InvalidArgument(format!(
16694                        "table {} manifest contains an invalid or duplicate run id",
16695                        entry.table_id
16696                    )));
16697                }
16698                let relative = relative_dir
16699                    .join(crate::engine::RUNS_DIR)
16700                    .join(format!("r-{}.sr", run.run_id as u64));
16701                let file = durable_root.open_regular(&relative)?;
16702                let mut reader = crate::sorted_run::RunReader::open_file(
16703                    file,
16704                    entry.schema.clone(),
16705                    kek.clone(),
16706                )?;
16707                let header = reader.header();
16708                if header.run_id != run.run_id
16709                    || header.level != run.level
16710                    || header.row_count != run.row_count
16711                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
16712                    || header.is_uniform_epoch() && header.epoch_created != 0
16713                    || header.schema_id > entry.schema.schema_id
16714                {
16715                    return Err(MongrelError::InvalidArgument(format!(
16716                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
16717                        entry.table_id,
16718                        run.run_id,
16719                        header.run_id,
16720                        header.level,
16721                        header.row_count,
16722                        header.epoch_created,
16723                        header.schema_id,
16724                        run.run_id,
16725                        run.level,
16726                        run.row_count,
16727                        run.epoch_created,
16728                        entry.schema.schema_id,
16729                    )));
16730                }
16731                if header.row_count != 0 {
16732                    maximum_row_id = Some(
16733                        maximum_row_id
16734                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
16735                    );
16736                }
16737                reader.validate_all_pages()?;
16738            }
16739            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
16740                return Err(MongrelError::InvalidArgument(format!(
16741                    "table {} next_row_id does not advance beyond persisted rows",
16742                    entry.table_id
16743                )));
16744            }
16745            for run in &manifest.retiring {
16746                if run.run_id >= u64::MAX as u128
16747                    || run.retire_epoch > manifest.current_epoch
16748                    || !run_ids.insert(run.run_id)
16749                {
16750                    return Err(MongrelError::InvalidArgument(format!(
16751                        "table {} manifest contains an invalid or aliased retired run",
16752                        entry.table_id
16753                    )));
16754                }
16755            }
16756            manifest.flushed_epoch
16757        } else {
16758            if !recovered_table_ids.contains(&entry.table_id) {
16759                return Err(MongrelError::NotFound(format!(
16760                    "live table {} manifest is missing",
16761                    entry.table_id
16762                )));
16763            }
16764            0
16765        };
16766        tables.insert(
16767            entry.table_id,
16768            RecoveryValidationTable {
16769                schema: entry.schema.clone(),
16770                flushed_epoch,
16771            },
16772        );
16773    }
16774
16775    let committed = records
16776        .iter()
16777        .filter_map(|record| match record.op {
16778            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
16779            _ => None,
16780        })
16781        .collect::<HashMap<_, _>>();
16782    let mut run_ids = HashSet::new();
16783    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
16784    for record in records {
16785        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
16786            continue;
16787        };
16788        match &record.op {
16789            Op::Put { table_id, rows } => {
16790                let table = validate_recovery_data_table_plan(
16791                    catalog,
16792                    &tables,
16793                    *table_id,
16794                    commit_epoch,
16795                    record.seq.0,
16796                )?;
16797                let decoded: Vec<crate::memtable::Row> =
16798                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
16799                        offset: record.seq.0,
16800                        reason: format!(
16801                            "committed Put payload for transaction {} could not be decoded: {error}",
16802                            record.txn_id
16803                        ),
16804                    })?;
16805                if let Some(table) = table {
16806                    for row in &decoded {
16807                        if !recovered_row_ids
16808                            .entry(*table_id)
16809                            .or_default()
16810                            .insert(row.row_id.0)
16811                        {
16812                            return Err(MongrelError::CorruptWal {
16813                                offset: record.seq.0,
16814                                reason: format!(
16815                                    "committed WAL repeats recovered row id {} for table {table_id}",
16816                                    row.row_id.0
16817                                ),
16818                            });
16819                        }
16820                        validate_recovered_row(&table.schema, row)?;
16821                    }
16822                }
16823            }
16824            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
16825                validate_recovery_data_table_plan(
16826                    catalog,
16827                    &tables,
16828                    *table_id,
16829                    commit_epoch,
16830                    record.seq.0,
16831                )?;
16832            }
16833            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
16834            Op::Ddl(DdlOp::ResetExternalTableState {
16835                name,
16836                generation_epoch,
16837            }) => {
16838                if *generation_epoch != commit_epoch {
16839                    return Err(MongrelError::CorruptWal {
16840                        offset: record.seq.0,
16841                        reason: format!(
16842                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
16843                        ),
16844                    });
16845                }
16846                validate_recovered_external_name(name)?;
16847            }
16848            Op::TxnCommit { added_runs, .. } => {
16849                for added in added_runs {
16850                    let Some(table) = validate_recovery_data_table_plan(
16851                        catalog,
16852                        &tables,
16853                        added.table_id,
16854                        commit_epoch,
16855                        record.seq.0,
16856                    )?
16857                    else {
16858                        continue;
16859                    };
16860                    if added.run_id >= u64::MAX as u128
16861                        || !run_ids.insert((added.table_id, added.run_id))
16862                    {
16863                        return Err(MongrelError::CorruptWal {
16864                            offset: record.seq.0,
16865                            reason: format!(
16866                                "duplicate or invalid recovered run {} for table {}",
16867                                added.run_id, added.table_id
16868                            ),
16869                        });
16870                    }
16871                    if commit_epoch <= table.flushed_epoch {
16872                        continue;
16873                    }
16874                    validate_planned_spilled_run(
16875                        durable_root,
16876                        record.txn_id,
16877                        commit_epoch,
16878                        added,
16879                        &table.schema,
16880                        kek.clone(),
16881                    )?;
16882                }
16883            }
16884            _ => {}
16885        }
16886    }
16887    Ok(())
16888}
16889
16890fn validate_recovery_data_table_plan<'a>(
16891    catalog: &Catalog,
16892    tables: &'a HashMap<u64, RecoveryValidationTable>,
16893    table_id: u64,
16894    commit_epoch: u64,
16895    offset: u64,
16896) -> Result<Option<&'a RecoveryValidationTable>> {
16897    let entry = catalog
16898        .tables
16899        .iter()
16900        .find(|entry| entry.table_id == table_id)
16901        .ok_or_else(|| MongrelError::CorruptWal {
16902            offset,
16903            reason: format!("committed record references unknown table {table_id}"),
16904        })?;
16905    if commit_epoch < entry.created_epoch {
16906        return Err(MongrelError::CorruptWal {
16907            offset,
16908            reason: format!(
16909                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16910                entry.created_epoch
16911            ),
16912        });
16913    }
16914    match entry.state {
16915        TableState::Dropped { at_epoch } => {
16916            let abandoned =
16917                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16918            if commit_epoch >= at_epoch && !abandoned {
16919                return Err(MongrelError::CorruptWal {
16920                    offset,
16921                    reason: format!(
16922                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16923                    ),
16924                });
16925            }
16926            Ok(None)
16927        }
16928        TableState::Live => {
16929            tables
16930                .get(&table_id)
16931                .map(Some)
16932                .ok_or_else(|| MongrelError::CorruptWal {
16933                    offset,
16934                    reason: format!("live table {table_id} has no recovery plan"),
16935                })
16936        }
16937        TableState::Building { .. } => Err(MongrelError::CorruptWal {
16938            offset,
16939            reason: format!("building table {table_id} was not normalized before recovery"),
16940        }),
16941    }
16942}
16943
16944fn validate_planned_spilled_run(
16945    root: &crate::durable_file::DurableRoot,
16946    txn_id: u64,
16947    commit_epoch: u64,
16948    added: &crate::wal::AddedRun,
16949    schema: &Schema,
16950    kek: Option<Arc<crate::encryption::Kek>>,
16951) -> Result<()> {
16952    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
16953    let destination = table
16954        .join(crate::engine::RUNS_DIR)
16955        .join(format!("r-{}.sr", added.run_id as u64));
16956    let pending = table
16957        .join("_txn")
16958        .join(txn_id.to_string())
16959        .join(format!("r-{}.sr", added.run_id as u64));
16960    let file = match root.open_regular(&destination) {
16961        Ok(file) => file,
16962        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
16963            root.open_regular(&pending).map_err(|pending_error| {
16964                if pending_error.kind() == std::io::ErrorKind::NotFound {
16965                    MongrelError::CorruptWal {
16966                        offset: commit_epoch,
16967                        reason: format!(
16968                            "committed spilled run {} for transaction {txn_id} is missing",
16969                            added.run_id
16970                        ),
16971                    }
16972                } else {
16973                    pending_error.into()
16974                }
16975            })?
16976        }
16977        Err(error) => return Err(error.into()),
16978    };
16979    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
16980    let header = reader.header();
16981    if header.run_id != added.run_id
16982        || header.content_hash != added.content_hash
16983        || header.row_count != added.row_count
16984        || header.level != added.level
16985        || header.min_row_id != added.min_row_id
16986        || header.max_row_id != added.max_row_id
16987        || header.schema_id != schema.schema_id
16988        || !header.is_uniform_epoch()
16989        || header.epoch_created != 0
16990    {
16991        return Err(MongrelError::CorruptWal {
16992            offset: commit_epoch,
16993            reason: format!(
16994                "committed spilled run {} metadata differs from WAL",
16995                added.run_id
16996            ),
16997        });
16998    }
16999    reader.validate_all_pages()?;
17000    Ok(())
17001}
17002
17003/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
17004///
17005/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
17006/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
17007/// 2 applies each committed data record (Put/Delete) to its table at the commit
17008/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
17009/// durable in a sorted run). Finally the shared epoch authority is raised to the
17010/// max committed epoch so the next commit continues monotonically.
17011/// The staged-write payload contract of a distributed-transaction write
17012/// intent (spec section 12.8). A participant in two-phase commit stages its
17013/// writes as opaque intent payloads (`WriteIntent::value_ref` in
17014/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
17015/// this engine-defined encoding is the whole contract. At prepare time the
17016/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
17017/// committed resolution they are applied through
17018/// [`Database::apply_staged_txn_writes`].
17019///
17020/// The encoding is bincode over this enum (the same codec the WAL frame
17021/// payloads use); discriminants are never reused.
17022#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17023pub enum StagedTxnWrite {
17024    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
17025    /// identical payload shape an `Op::Put` WAL record carries). Row commit
17026    /// epochs are placeholders — the resolution apply restamps every row at
17027    /// the synthetic commit epoch.
17028    Put {
17029        /// The mounted table the rows target.
17030        table_id: u64,
17031        /// Bincode `Vec<crate::memtable::Row>`.
17032        rows: Vec<u8>,
17033    },
17034    /// Staged row deletes by row id.
17035    Delete {
17036        /// The mounted table the deletes target.
17037        table_id: u64,
17038        /// Row ids (`crate::RowId` values) to delete.
17039        row_ids: Vec<u64>,
17040    },
17041}
17042
17043impl StagedTxnWrite {
17044    /// Serializes deterministically (bincode over the enum).
17045    pub fn encode(&self) -> Result<Vec<u8>> {
17046        Ok(bincode::serialize(self)?)
17047    }
17048
17049    /// Decodes one staged-write payload, failing closed on malformed input.
17050    pub fn decode(bytes: &[u8]) -> Result<Self> {
17051        Ok(bincode::deserialize(bytes)?)
17052    }
17053}
17054
17055/// Leader-side spill translation for the replicated write path (spec section
17056/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
17057///
17058/// A transaction whose staged puts exceed the spill threshold commits with
17059/// its rows in a leader-local sorted run: the commit marker's `added_runs`
17060/// links the run file and the rows also ride the WAL as logical
17061/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
17062/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
17063/// and because the raft entry is already quorum-committed, an apply-time
17064/// rejection wedges the whole group's apply stream. The leader therefore
17065/// translates the staged record sequence **before proposal**:
17066///
17067/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
17068///   (identical row bytes; recovery restamps the rows at the commit epoch),
17069/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
17070///   reach a replica),
17071/// - every other record passes through byte-identical.
17072///
17073/// The standalone commit path is untouched: the leader's own WAL keeps the
17074/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
17075/// links the run; this function reads but never mutates its input.
17076///
17077/// Translation is total for any sequence the commit sequencer actually
17078/// produced. As a fail-closed guard against malformed or truncated captures,
17079/// the sequence is structurally validated (one transaction, exactly one
17080/// trailing commit marker), every spill payload must decode, and every linked
17081/// run's rows must be provably present as logical records (the row-id range
17082/// covers `row_count` rows); a violation rejects the proposal with
17083/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
17084/// post-commit. (Taxonomy: `InvalidArgument` maps to
17085/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
17086/// disagreement that is never retried unchanged. The normative spec category
17087/// for "the commit was not applied; only a fresh transaction may succeed" is
17088/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
17089/// `error.rs`, which is outside this change's file scope — tracked as a
17090/// follow-up.)
17091pub fn translate_records_for_replication(
17092    records: &[crate::wal::Record],
17093) -> Result<Vec<crate::wal::Record>> {
17094    use crate::wal::Op;
17095
17096    // Structural validation mirrors `apply_replicated_records`: one
17097    // transaction, exactly one commit marker, at the tail.
17098    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
17099        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
17100    })?;
17101    if records.iter().any(|record| record.txn_id != txn_id) {
17102        return Err(MongrelError::InvalidArgument(
17103            "replicated transaction payload mixes transaction ids".into(),
17104        ));
17105    }
17106    let commits = records
17107        .iter()
17108        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
17109        .count();
17110    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
17111        return Err(MongrelError::InvalidArgument(
17112            "replicated transaction payload must end in exactly one commit marker".into(),
17113        ));
17114    }
17115
17116    // Decode every logical spill payload now: a payload that cannot decode
17117    // here would fail every replica's apply identically — reject at propose
17118    // time instead.
17119    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
17120    for record in records {
17121        if let Op::SpilledRows { table_id, rows } = &record.op {
17122            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
17123                MongrelError::InvalidArgument(format!(
17124                    "spilled row payload for table {table_id} cannot decode for replication: \
17125                         {error}"
17126                ))
17127            })?;
17128            spilled_rows.entry(*table_id).or_default().extend(chunk);
17129        }
17130    }
17131
17132    // Coverage proof: every run the commit marker links must have its full
17133    // row content present as logical records, or replicas would silently
17134    // lose those rows.
17135    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
17136        unreachable!("one trailing commit marker validated above");
17137    };
17138    for run in added_runs {
17139        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
17140            MongrelError::InvalidArgument(format!(
17141                "commit links spilled run {} for table {} but carries no logical row records \
17142                 for it",
17143                run.run_id, run.table_id
17144            ))
17145        })?;
17146        let covered = rows
17147            .iter()
17148            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
17149            .count() as u64;
17150        if covered != run.row_count {
17151            return Err(MongrelError::InvalidArgument(format!(
17152                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
17153                 row records cover {} rows",
17154                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
17155            )));
17156        }
17157    }
17158
17159    // Translate: spill payloads become ordinary puts; the commit marker no
17160    // longer references leader-local run files.
17161    let translated = records
17162        .iter()
17163        .map(|record| {
17164            let op = match &record.op {
17165                Op::SpilledRows { table_id, rows } => Op::Put {
17166                    table_id: *table_id,
17167                    rows: rows.clone(),
17168                },
17169                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
17170                    epoch: *epoch,
17171                    added_runs: Vec::new(),
17172                },
17173                op => op.clone(),
17174            };
17175            crate::wal::Record::new(record.seq, record.txn_id, op)
17176        })
17177        .collect();
17178    Ok(translated)
17179}
17180
17181fn recover_shared_wal(
17182    durable_root: &crate::durable_file::DurableRoot,
17183    tables: &HashMap<u64, TableHandle>,
17184    catalog: &Catalog,
17185    epoch: &EpochAuthority,
17186    records: &[crate::wal::Record],
17187) -> Result<()> {
17188    use crate::memtable::Row;
17189    use crate::wal::{DdlOp, Op};
17190
17191    // Pass 1: committed-txn outcomes + collect spilled-run info.
17192    let mut committed: HashMap<u64, u64> = HashMap::new();
17193    // Physical HLC micros from Op::CommitTimestamp, keyed by txn_id (P0.5).
17194    let mut commit_ts_by_txn: HashMap<u64, mongreldb_types::hlc::HlcTimestamp> = HashMap::new();
17195    let mut spilled_to_link: Vec<(
17196        u64, /*txn_id*/
17197        u64, /*epoch*/
17198        Vec<crate::wal::AddedRun>,
17199    )> = Vec::new();
17200    for r in records {
17201        match &r.op {
17202            Op::CommitTimestamp { unix_nanos } => {
17203                commit_ts_by_txn.insert(
17204                    r.txn_id,
17205                    mongreldb_types::hlc::HlcTimestamp {
17206                        physical_micros: unix_nanos / 1_000,
17207                        logical: 0,
17208                        node_tiebreaker: 0,
17209                    },
17210                );
17211            }
17212            Op::TxnCommit {
17213                epoch: ce,
17214                ref added_runs,
17215            } => {
17216                committed.insert(r.txn_id, *ce);
17217                if !added_runs.is_empty() {
17218                    spilled_to_link.push((r.txn_id, *ce, added_runs.clone()));
17219                }
17220            }
17221            _ => {}
17222        }
17223    }
17224    for record in records {
17225        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
17226            continue;
17227        };
17228        match &record.op {
17229            Op::Put { table_id, .. }
17230            | Op::Delete { table_id, .. }
17231            | Op::TruncateTable { table_id } => {
17232                validate_recovered_data_table(
17233                    catalog,
17234                    tables,
17235                    *table_id,
17236                    commit_epoch,
17237                    record.seq.0,
17238                )?;
17239            }
17240            Op::TxnCommit { added_runs, .. } => {
17241                for run in added_runs {
17242                    validate_recovered_data_table(
17243                        catalog,
17244                        tables,
17245                        run.table_id,
17246                        commit_epoch,
17247                        record.seq.0,
17248                    )?;
17249                }
17250            }
17251            _ => {}
17252        }
17253    }
17254    let truncated_transactions: HashSet<(u64, u64)> = records
17255        .iter()
17256        .filter_map(|record| {
17257            committed.get(&record.txn_id)?;
17258            match record.op {
17259                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
17260                _ => None,
17261            }
17262        })
17263        .collect();
17264
17265    // Pass 2: stage data per table, gated by flushed_epoch.
17266    enum ExternalRecoveryAction {
17267        Write { name: String, state: Vec<u8> },
17268        Reset { name: String },
17269    }
17270    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
17271    let mut external_actions = Vec::new();
17272    let mut max_epoch = epoch.visible().0;
17273    for r in records.iter().cloned() {
17274        let Some(&ce) = committed.get(&r.txn_id) else {
17275            continue; // aborted / in-flight — discard
17276        };
17277        let commit_epoch = Epoch(ce);
17278        max_epoch = max_epoch.max(ce);
17279        match r.op {
17280            Op::Put { table_id, rows } => {
17281                // Skip if this table already flushed past the commit epoch.
17282                let skip = tables
17283                    .get(&table_id)
17284                    .map(|h| h.lock().flushed_epoch() >= ce)
17285                    .unwrap_or(true);
17286                if skip {
17287                    continue;
17288                }
17289                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
17290                    MongrelError::CorruptWal {
17291                        offset: r.seq.0,
17292                        reason: format!(
17293                            "committed Put payload for transaction {} could not be decoded: {error}",
17294                            r.txn_id
17295                        ),
17296                    }
17297                })?;
17298                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
17299                // at pending_epoch which equals the commit epoch, but be robust).
17300                // P0.5: prefer the durable CommitTimestamp HLC for this txn when
17301                // present so recovery restores HLC-authoritative versions.
17302                let txn_commit_ts = commit_ts_by_txn.get(&r.txn_id).copied();
17303                let rows: Vec<Row> = rows
17304                    .into_iter()
17305                    .map(|mut row| {
17306                        row.committed_epoch = commit_epoch;
17307                        if row.commit_ts.is_none() {
17308                            row.commit_ts = txn_commit_ts;
17309                        }
17310                        row
17311                    })
17312                    .collect();
17313                let entry = stage
17314                    .entry(table_id)
17315                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17316                entry.0.extend(rows);
17317                entry.3 = commit_epoch;
17318            }
17319            Op::Delete { table_id, row_ids } => {
17320                let skip = tables
17321                    .get(&table_id)
17322                    .map(|h| h.lock().flushed_epoch() >= ce)
17323                    .unwrap_or(true);
17324                if skip {
17325                    continue;
17326                }
17327                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
17328                let entry = stage
17329                    .entry(table_id)
17330                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17331                entry.1.extend(dels);
17332                entry.3 = commit_epoch;
17333            }
17334            Op::TruncateTable { table_id } => {
17335                let skip = tables
17336                    .get(&table_id)
17337                    .map(|h| h.lock().flushed_epoch() >= ce)
17338                    .unwrap_or(true);
17339                if skip {
17340                    continue;
17341                }
17342                stage.insert(
17343                    table_id,
17344                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
17345                );
17346            }
17347            Op::ExternalTableState { name, state } => {
17348                let current_generation = catalog
17349                    .external_tables
17350                    .iter()
17351                    .find(|entry| entry.name == name)
17352                    .map(|entry| entry.created_epoch);
17353                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
17354                    validate_recovered_external_name(&name)?;
17355                    external_actions.push(ExternalRecoveryAction::Write { name, state });
17356                }
17357            }
17358            Op::Ddl(DdlOp::ResetExternalTableState {
17359                name,
17360                generation_epoch,
17361            }) => {
17362                if generation_epoch != ce {
17363                    return Err(MongrelError::CorruptWal {
17364                        offset: r.seq.0,
17365                        reason: format!(
17366                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
17367                    ),
17368                    });
17369                }
17370                validate_recovered_external_name(&name)?;
17371                external_actions.push(ExternalRecoveryAction::Reset { name });
17372            }
17373            Op::Flush { .. }
17374            | Op::TxnCommit { .. }
17375            | Op::TxnAbort
17376            | Op::Ddl(_)
17377            | Op::BeforeImage { .. }
17378            | Op::CommitTimestamp { .. }
17379            | Op::SpilledRows { .. } => {}
17380        }
17381    }
17382    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
17383        added_runs.retain(|added| {
17384            tables
17385                .get(&added.table_id)
17386                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
17387        });
17388    }
17389    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
17390    validate_recovery_table_stages(tables, &stage)?;
17391    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
17392
17393    // All WAL payloads, catalog generations, table stages, and immutable run
17394    // identities have now been validated. Only this application phase mutates
17395    // the database tree.
17396    for action in external_actions {
17397        match action {
17398            ExternalRecoveryAction::Write { name, state } => {
17399                write_external_state_file(durable_root, &name, &state)?;
17400            }
17401            ExternalRecoveryAction::Reset { name } => {
17402                durable_root.create_directory_all(VTAB_DIR)?;
17403                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
17404            }
17405        }
17406    }
17407    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
17408        let Some(handle) = tables.get(&table_id) else {
17409            continue;
17410        };
17411        let mut t = handle.lock();
17412        if let Some(epoch) = truncate_epoch {
17413            t.apply_truncate(epoch);
17414        }
17415        t.recover_apply(rows, deletes)?;
17416        // The WAL can be newer than the copied/persisted manifest after a
17417        // crash or replication apply. Rebuild O(1) count metadata from the
17418        // recovered state before endorsing the commit epoch in the manifest.
17419        let rows = t.visible_rows(Snapshot::unbounded())?;
17420        t.live_count = rows.len() as u64;
17421        // Recovery can replay older row commits while a newer spilled run is
17422        // already linked by the copied manifest. Never move that manifest's
17423        // epoch behind its existing run references.
17424        t.persist_manifest(table_epoch.max(epoch.visible()))?;
17425    }
17426
17427    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
17428    // between TxnCommit sync and the publish phase leaves the run in
17429    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
17430    for (txn_id, ce, added_runs) in &spilled_to_link {
17431        for ar in added_runs {
17432            let Some(handle) = tables.get(&ar.table_id) else {
17433                continue;
17434            };
17435            let mut t = handle.lock();
17436            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
17437            let destination = table_dir
17438                .join(crate::engine::RUNS_DIR)
17439                .join(format!("r-{}.sr", ar.run_id));
17440            match durable_root.open_regular(&destination) {
17441                Ok(_) => {}
17442                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17443                    let pending = table_dir
17444                        .join("_txn")
17445                        .join(txn_id.to_string())
17446                        .join(format!("r-{}.sr", ar.run_id));
17447                    durable_root.rename_file_new(&pending, &destination)?;
17448                }
17449                Err(error) => return Err(error.into()),
17450            }
17451            // Only link a run whose file is actually present, and never re-link
17452            // one the publish phase already persisted into the manifest (which is
17453            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
17454            // until segment GC). `recover_spilled_run` is idempotent + reconciles
17455            // `live_count`/indexes only when the run is genuinely new.
17456            let linked = t.recover_spilled_run(crate::manifest::RunRef {
17457                run_id: ar.run_id,
17458                level: ar.level,
17459                epoch_created: *ce,
17460                row_count: ar.row_count,
17461            });
17462            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
17463            if replaced {
17464                t.set_flushed_epoch(Epoch(*ce));
17465            }
17466            if linked || replaced {
17467                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
17468            }
17469        }
17470    }
17471
17472    epoch.advance_recovered(Epoch(max_epoch));
17473    Ok(())
17474}
17475
17476fn reconcile_recovered_table_metadata(
17477    tables: &HashMap<u64, TableHandle>,
17478    epoch: Epoch,
17479) -> Result<()> {
17480    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
17481    table_ids.sort_unstable();
17482    let mut plans = Vec::with_capacity(table_ids.len());
17483    for table_id in &table_ids {
17484        let handle = tables.get(table_id).ok_or_else(|| {
17485            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17486        })?;
17487        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
17488    }
17489    // Every table's data and metadata have been decoded successfully. Publish
17490    // repairs only after the complete database-wide plan is known valid.
17491    for (table_id, plan) in plans {
17492        let handle = tables.get(&table_id).ok_or_else(|| {
17493            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17494        })?;
17495        handle.lock().apply_recovered_metadata(plan, epoch)?;
17496    }
17497    Ok(())
17498}
17499
17500fn validate_recovered_external_name(name: &str) -> Result<()> {
17501    if name.is_empty()
17502        || !name.chars().all(|character| {
17503            character.is_ascii_alphanumeric() || character == '_' || character == '-'
17504        })
17505    {
17506        return Err(MongrelError::CorruptWal {
17507            offset: 0,
17508            reason: format!("unsafe recovered external-table name {name:?}"),
17509        });
17510    }
17511    Ok(())
17512}
17513
17514fn validate_recovery_table_stages(
17515    tables: &HashMap<u64, TableHandle>,
17516    stages: &HashMap<u64, RecoveryTableStage>,
17517) -> Result<()> {
17518    for (table_id, (rows, _, _, _)) in stages {
17519        let handle = tables
17520            .get(table_id)
17521            .ok_or_else(|| MongrelError::CorruptWal {
17522                offset: *table_id,
17523                reason: format!("recovery stage references unmounted table {table_id}"),
17524            })?;
17525        let table = handle.lock();
17526        // Force all existing immutable runs through their integrity/decode path
17527        // before any other table manifest can be changed.
17528        table.visible_rows(Snapshot::unbounded())?;
17529        for row in rows {
17530            validate_recovered_row(table.schema(), row)?;
17531        }
17532    }
17533    Ok(())
17534}
17535
17536fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
17537    if row.deleted || row.row_id.0 == u64::MAX {
17538        return Err(MongrelError::CorruptWal {
17539            offset: row.row_id.0,
17540            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
17541        });
17542    }
17543    let cells = row
17544        .columns
17545        .iter()
17546        .map(|(column, value)| (*column, value.clone()))
17547        .collect::<Vec<_>>();
17548    schema
17549        .validate_persisted_values(&cells)
17550        .map_err(|error| MongrelError::CorruptWal {
17551            offset: row.row_id.0,
17552            reason: format!("recovered row violates table schema: {error}"),
17553        })?;
17554    if schema.auto_increment_column().is_some_and(|column| {
17555        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
17556    }) {
17557        return Err(MongrelError::CorruptWal {
17558            offset: row.row_id.0,
17559            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
17560        });
17561    }
17562    Ok(())
17563}
17564
17565fn validate_recovery_spilled_runs(
17566    root: &crate::durable_file::DurableRoot,
17567    tables: &HashMap<u64, TableHandle>,
17568    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
17569) -> Result<()> {
17570    let mut identities = HashSet::new();
17571    for (txn_id, commit_epoch, added_runs) in spilled {
17572        for added in added_runs {
17573            if added.run_id >= u64::MAX as u128 {
17574                return Err(MongrelError::CorruptWal {
17575                    offset: *commit_epoch,
17576                    reason: format!(
17577                        "recovered run id {} exceeds the on-disk namespace",
17578                        added.run_id
17579                    ),
17580                });
17581            }
17582            let Some(handle) = tables.get(&added.table_id) else {
17583                continue;
17584            };
17585            if !identities.insert((added.table_id, added.run_id)) {
17586                return Err(MongrelError::CorruptWal {
17587                    offset: *commit_epoch,
17588                    reason: format!(
17589                        "duplicate recovered run {} for table {}",
17590                        added.run_id, added.table_id
17591                    ),
17592                });
17593            }
17594            let table = handle.lock();
17595            validate_planned_spilled_run(
17596                root,
17597                *txn_id,
17598                *commit_epoch,
17599                added,
17600                table.schema(),
17601                table.kek(),
17602            )?;
17603        }
17604    }
17605    Ok(())
17606}
17607
17608fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
17609    match condition {
17610        ProcedureCondition::Pk { .. } => {
17611            if schema.primary_key().is_none() {
17612                return Err(MongrelError::InvalidArgument(
17613                    "procedure condition Pk references a table without a primary key".into(),
17614                ));
17615            }
17616        }
17617        ProcedureCondition::BitmapEq { column_id, .. }
17618        | ProcedureCondition::BitmapIn { column_id, .. }
17619        | ProcedureCondition::Range { column_id, .. }
17620        | ProcedureCondition::RangeF64 { column_id, .. }
17621        | ProcedureCondition::IsNull { column_id }
17622        | ProcedureCondition::IsNotNull { column_id }
17623        | ProcedureCondition::FmContains { column_id, .. } => {
17624            validate_column_id(*column_id, schema)?;
17625        }
17626    }
17627    Ok(())
17628}
17629
17630fn bind_procedure_args(
17631    procedure: &StoredProcedure,
17632    mut args: HashMap<String, crate::Value>,
17633) -> Result<HashMap<String, crate::Value>> {
17634    let mut out = HashMap::new();
17635    for param in &procedure.params {
17636        let value = match args.remove(&param.name) {
17637            Some(value) => value,
17638            None => param.default.clone().ok_or_else(|| {
17639                MongrelError::InvalidArgument(format!(
17640                    "missing required procedure parameter {:?}",
17641                    param.name
17642                ))
17643            })?,
17644        };
17645        if !param.nullable && matches!(value, crate::Value::Null) {
17646            return Err(MongrelError::InvalidArgument(format!(
17647                "procedure parameter {:?} must not be NULL",
17648                param.name
17649            )));
17650        }
17651        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
17652            return Err(MongrelError::InvalidArgument(format!(
17653                "procedure parameter {:?} has wrong type",
17654                param.name
17655            )));
17656        }
17657        out.insert(param.name.clone(), value);
17658    }
17659    if let Some(extra) = args.keys().next() {
17660        return Err(MongrelError::InvalidArgument(format!(
17661            "unknown procedure parameter {extra:?}"
17662        )));
17663    }
17664    Ok(out)
17665}
17666
17667fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
17668    matches!(
17669        (value, ty),
17670        (crate::Value::Bool(_), crate::TypeId::Bool)
17671            | (crate::Value::Int64(_), crate::TypeId::Int8)
17672            | (crate::Value::Int64(_), crate::TypeId::Int16)
17673            | (crate::Value::Int64(_), crate::TypeId::Int32)
17674            | (crate::Value::Int64(_), crate::TypeId::Int64)
17675            | (crate::Value::Int64(_), crate::TypeId::UInt8)
17676            | (crate::Value::Int64(_), crate::TypeId::UInt16)
17677            | (crate::Value::Int64(_), crate::TypeId::UInt32)
17678            | (crate::Value::Int64(_), crate::TypeId::UInt64)
17679            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
17680            | (crate::Value::Int64(_), crate::TypeId::Date32)
17681            | (crate::Value::Float64(_), crate::TypeId::Float32)
17682            | (crate::Value::Float64(_), crate::TypeId::Float64)
17683            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
17684            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
17685            | (
17686                crate::Value::GeneratedEmbedding(_),
17687                crate::TypeId::Embedding { .. }
17688            )
17689    )
17690}
17691
17692fn eval_cells(
17693    cells: &[crate::procedure::ProcedureCell],
17694    args: &HashMap<String, crate::Value>,
17695    outputs: &HashMap<String, ProcedureCallOutput>,
17696) -> Result<Vec<(u16, crate::Value)>> {
17697    cells
17698        .iter()
17699        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
17700        .collect()
17701}
17702
17703fn eval_condition(
17704    condition: &ProcedureCondition,
17705    args: &HashMap<String, crate::Value>,
17706    outputs: &HashMap<String, ProcedureCallOutput>,
17707) -> Result<crate::Condition> {
17708    Ok(match condition {
17709        ProcedureCondition::Pk { value } => {
17710            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
17711        }
17712        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
17713            column_id: *column_id,
17714            value: eval_value(value, args, outputs)?.encode_key(),
17715        },
17716        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
17717            column_id: *column_id,
17718            values: values
17719                .iter()
17720                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
17721                .collect::<Result<Vec<_>>>()?,
17722        },
17723        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
17724            column_id: *column_id,
17725            lo: expect_i64(eval_value(lo, args, outputs)?)?,
17726            hi: expect_i64(eval_value(hi, args, outputs)?)?,
17727        },
17728        ProcedureCondition::RangeF64 {
17729            column_id,
17730            lo,
17731            lo_inclusive,
17732            hi,
17733            hi_inclusive,
17734        } => crate::Condition::RangeF64 {
17735            column_id: *column_id,
17736            lo: expect_f64(eval_value(lo, args, outputs)?)?,
17737            lo_inclusive: *lo_inclusive,
17738            hi: expect_f64(eval_value(hi, args, outputs)?)?,
17739            hi_inclusive: *hi_inclusive,
17740        },
17741        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
17742            column_id: *column_id,
17743        },
17744        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
17745            column_id: *column_id,
17746        },
17747        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
17748            column_id: *column_id,
17749            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
17750        },
17751    })
17752}
17753
17754fn eval_value(
17755    value: &ProcedureValue,
17756    args: &HashMap<String, crate::Value>,
17757    outputs: &HashMap<String, ProcedureCallOutput>,
17758) -> Result<crate::Value> {
17759    match value {
17760        ProcedureValue::Literal(value) => Ok(value.clone()),
17761        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
17762            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17763        }),
17764        ProcedureValue::StepScalar(id) => match outputs.get(id) {
17765            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
17766            _ => Err(MongrelError::InvalidArgument(format!(
17767                "procedure step {id:?} did not return a scalar"
17768            ))),
17769        },
17770        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
17771            Err(MongrelError::InvalidArgument(
17772                "row-valued procedure reference cannot be used as a scalar".into(),
17773            ))
17774        }
17775        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
17776            "structured procedure value cannot be used as a scalar cell".into(),
17777        )),
17778    }
17779}
17780
17781fn eval_return_output(
17782    value: &ProcedureValue,
17783    args: &HashMap<String, crate::Value>,
17784    outputs: &HashMap<String, ProcedureCallOutput>,
17785) -> Result<ProcedureCallOutput> {
17786    match value {
17787        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
17788        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
17789            args.get(name).cloned().ok_or_else(|| {
17790                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17791            })?,
17792        )),
17793        ProcedureValue::StepRows(id)
17794        | ProcedureValue::StepRow(id)
17795        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
17796            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
17797        }),
17798        ProcedureValue::Object(fields) => {
17799            let mut out = Vec::with_capacity(fields.len());
17800            for (name, value) in fields {
17801                out.push((name.clone(), eval_return_output(value, args, outputs)?));
17802            }
17803            Ok(ProcedureCallOutput::Object(out))
17804        }
17805        ProcedureValue::Array(values) => {
17806            let mut out = Vec::with_capacity(values.len());
17807            for value in values {
17808                out.push(eval_return_output(value, args, outputs)?);
17809            }
17810            Ok(ProcedureCallOutput::Array(out))
17811        }
17812    }
17813}
17814
17815fn expect_i64(value: crate::Value) -> Result<i64> {
17816    match value {
17817        crate::Value::Int64(value) => Ok(value),
17818        _ => Err(MongrelError::InvalidArgument(
17819            "procedure value must be Int64".into(),
17820        )),
17821    }
17822}
17823
17824fn expect_f64(value: crate::Value) -> Result<f64> {
17825    match value {
17826        crate::Value::Float64(value) => Ok(value),
17827        _ => Err(MongrelError::InvalidArgument(
17828            "procedure value must be Float64".into(),
17829        )),
17830    }
17831}
17832
17833fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
17834    match value {
17835        crate::Value::Bytes(value) => Ok(value),
17836        _ => Err(MongrelError::InvalidArgument(
17837            "procedure value must be Bytes".into(),
17838        )),
17839    }
17840}
17841
17842fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
17843    if schema.columns.iter().any(|c| c.id == column_id) {
17844        Ok(())
17845    } else {
17846        Err(MongrelError::InvalidArgument(format!(
17847            "unknown column id {column_id}"
17848        )))
17849    }
17850}
17851
17852fn trigger_matches_event(
17853    trigger: &StoredTrigger,
17854    event: &WriteEvent,
17855    cat: &Catalog,
17856) -> Result<bool> {
17857    if trigger.event != event.kind {
17858        return Ok(false);
17859    }
17860    let TriggerTarget::Table(target) = &trigger.target else {
17861        return Ok(false);
17862    };
17863    if target != &event.table {
17864        return Ok(false);
17865    }
17866    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
17867        let schema = &cat
17868            .live(target)
17869            .ok_or_else(|| {
17870                MongrelError::InvalidArgument(format!(
17871                    "trigger {:?} references unknown table {target:?}",
17872                    trigger.name
17873                ))
17874            })?
17875            .schema;
17876        let mut watched = Vec::with_capacity(trigger.update_of.len());
17877        for name in &trigger.update_of {
17878            let col = schema.column(name).ok_or_else(|| {
17879                MongrelError::InvalidArgument(format!(
17880                    "trigger {:?} references unknown UPDATE OF column {name:?}",
17881                    trigger.name
17882                ))
17883            })?;
17884            watched.push(col.id);
17885        }
17886        if !event
17887            .changed_columns
17888            .iter()
17889            .any(|column_id| watched.contains(column_id))
17890        {
17891            return Ok(false);
17892        }
17893    }
17894    Ok(true)
17895}
17896
17897fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
17898    let mut ids = std::collections::BTreeSet::new();
17899    if let Some(old) = old {
17900        ids.extend(old.columns.keys().copied());
17901    }
17902    if let Some(new) = new {
17903        ids.extend(new.columns.keys().copied());
17904    }
17905    ids.into_iter()
17906        .filter(|id| {
17907            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
17908        })
17909        .collect()
17910}
17911
17912fn eval_trigger_cells(
17913    cells: &[crate::trigger::TriggerCell],
17914    event: &WriteEvent,
17915    selected: Option<&TriggerRowImage>,
17916) -> Result<Vec<(u16, Value)>> {
17917    cells
17918        .iter()
17919        .map(|cell| {
17920            Ok((
17921                cell.column_id,
17922                eval_trigger_value(&cell.value, event, selected)?,
17923            ))
17924        })
17925        .collect()
17926}
17927
17928fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
17929    match expr {
17930        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
17931            Value::Bool(value) => Ok(value),
17932            Value::Null => Ok(false),
17933            other => Err(MongrelError::InvalidArgument(format!(
17934                "trigger WHEN value must be boolean, got {other:?}"
17935            ))),
17936        },
17937        TriggerExpr::Eq { left, right } => Ok(values_equal(
17938            &eval_trigger_value(left, event, None)?,
17939            &eval_trigger_value(right, event, None)?,
17940        )),
17941        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
17942            &eval_trigger_value(left, event, None)?,
17943            &eval_trigger_value(right, event, None)?,
17944        )),
17945        TriggerExpr::Lt { left, right } => match value_order(
17946            &eval_trigger_value(left, event, None)?,
17947            &eval_trigger_value(right, event, None)?,
17948        ) {
17949            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17950            None => Ok(false),
17951        },
17952        TriggerExpr::Lte { left, right } => match value_order(
17953            &eval_trigger_value(left, event, None)?,
17954            &eval_trigger_value(right, event, None)?,
17955        ) {
17956            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17957            None => Ok(false),
17958        },
17959        TriggerExpr::Gt { left, right } => match value_order(
17960            &eval_trigger_value(left, event, None)?,
17961            &eval_trigger_value(right, event, None)?,
17962        ) {
17963            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17964            None => Ok(false),
17965        },
17966        TriggerExpr::Gte { 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::IsNull(value) => Ok(matches!(
17974            eval_trigger_value(value, event, None)?,
17975            Value::Null
17976        )),
17977        TriggerExpr::IsNotNull(value) => Ok(!matches!(
17978            eval_trigger_value(value, event, None)?,
17979            Value::Null
17980        )),
17981        TriggerExpr::And { left, right } => {
17982            if !eval_trigger_expr(left, event)? {
17983                Ok(false)
17984            } else {
17985                Ok(eval_trigger_expr(right, event)?)
17986            }
17987        }
17988        TriggerExpr::Or { left, right } => {
17989            if eval_trigger_expr(left, event)? {
17990                Ok(true)
17991            } else {
17992                Ok(eval_trigger_expr(right, event)?)
17993            }
17994        }
17995        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
17996    }
17997}
17998
17999fn eval_trigger_condition(
18000    condition: &TriggerCondition,
18001    event: &WriteEvent,
18002    selected: &TriggerRowImage,
18003    schema: &Schema,
18004) -> Result<bool> {
18005    match condition {
18006        TriggerCondition::Pk { value } => {
18007            let pk = schema.primary_key().ok_or_else(|| {
18008                MongrelError::InvalidArgument(
18009                    "trigger condition Pk references a table without a primary key".into(),
18010                )
18011            })?;
18012            let lhs = eval_trigger_value(value, event, Some(selected))?;
18013            Ok(values_equal(
18014                &lhs,
18015                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
18016            ))
18017        }
18018        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
18019            selected.columns.get(column_id).unwrap_or(&Value::Null),
18020            &eval_trigger_value(value, event, Some(selected))?,
18021        )),
18022        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
18023            selected.columns.get(column_id).unwrap_or(&Value::Null),
18024            &eval_trigger_value(value, event, Some(selected))?,
18025        )),
18026        TriggerCondition::Lt { column_id, value } => match value_order(
18027            selected.columns.get(column_id).unwrap_or(&Value::Null),
18028            &eval_trigger_value(value, event, Some(selected))?,
18029        ) {
18030            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
18031            None => Ok(false),
18032        },
18033        TriggerCondition::Lte { column_id, value } => match value_order(
18034            selected.columns.get(column_id).unwrap_or(&Value::Null),
18035            &eval_trigger_value(value, event, Some(selected))?,
18036        ) {
18037            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
18038            None => Ok(false),
18039        },
18040        TriggerCondition::Gt { column_id, value } => match value_order(
18041            selected.columns.get(column_id).unwrap_or(&Value::Null),
18042            &eval_trigger_value(value, event, Some(selected))?,
18043        ) {
18044            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
18045            None => Ok(false),
18046        },
18047        TriggerCondition::Gte { 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::IsNull { column_id } => Ok(matches!(
18055            selected.columns.get(column_id),
18056            None | Some(Value::Null)
18057        )),
18058        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
18059            selected.columns.get(column_id),
18060            None | Some(Value::Null)
18061        )),
18062        TriggerCondition::And { left, right } => {
18063            if !eval_trigger_condition(left, event, selected, schema)? {
18064                Ok(false)
18065            } else {
18066                Ok(eval_trigger_condition(right, event, selected, schema)?)
18067            }
18068        }
18069        TriggerCondition::Or { left, right } => {
18070            if eval_trigger_condition(left, event, selected, schema)? {
18071                Ok(true)
18072            } else {
18073                Ok(eval_trigger_condition(right, event, selected, schema)?)
18074            }
18075        }
18076        TriggerCondition::Not(condition) => {
18077            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
18078        }
18079    }
18080}
18081
18082fn eval_trigger_value(
18083    value: &TriggerValue,
18084    event: &WriteEvent,
18085    selected: Option<&TriggerRowImage>,
18086) -> Result<Value> {
18087    match value {
18088        TriggerValue::Literal(value) => Ok(value.clone()),
18089        TriggerValue::NewColumn(column_id) => event
18090            .new
18091            .as_ref()
18092            .and_then(|row| row.columns.get(column_id))
18093            .cloned()
18094            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
18095        TriggerValue::OldColumn(column_id) => event
18096            .old
18097            .as_ref()
18098            .and_then(|row| row.columns.get(column_id))
18099            .cloned()
18100            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
18101        TriggerValue::SelectedColumn(column_id) => selected
18102            .and_then(|row| row.columns.get(column_id))
18103            .cloned()
18104            .ok_or_else(|| {
18105                MongrelError::InvalidArgument("SELECTED column is not available".into())
18106            }),
18107    }
18108}
18109
18110fn values_equal(left: &Value, right: &Value) -> bool {
18111    match (left, right) {
18112        (Value::Null, Value::Null) => true,
18113        (Value::Bool(a), Value::Bool(b)) => a == b,
18114        (Value::Int64(a), Value::Int64(b)) => a == b,
18115        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
18116        (Value::Bytes(a), Value::Bytes(b)) => a == b,
18117        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => {
18118            let a = a.as_embedding().unwrap();
18119            let b = b.as_embedding().unwrap();
18120            a.len() == b.len()
18121                && a.iter()
18122                    .zip(b.iter())
18123                    .all(|(a, b)| a.to_bits() == b.to_bits())
18124        }
18125        _ => false,
18126    }
18127}
18128
18129fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
18130    match (left, right) {
18131        (Value::Null, _) | (_, Value::Null) => None,
18132        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
18133        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
18134        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18135        // This matches the spec but can lose precision for i64 values above 2^53.
18136        (Value::Int64(a), Value::Float64(b)) => {
18137            let af = *a as f64;
18138            Some(af.total_cmp(b))
18139        }
18140        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18141        // This matches the spec but can lose precision for i64 values above 2^53.
18142        (Value::Float64(a), Value::Int64(b)) => {
18143            let bf = *b as f64;
18144            Some(a.total_cmp(&bf))
18145        }
18146        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
18147        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
18148        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => None,
18149        _ => None,
18150    }
18151}
18152
18153fn trigger_message(value: Value) -> String {
18154    match value {
18155        Value::Null => "NULL".into(),
18156        Value::Bool(value) => value.to_string(),
18157        Value::Int64(value) => value.to_string(),
18158        Value::Float64(value) => value.to_string(),
18159        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
18160        Value::Embedding(value) => format!("{value:?}"),
18161        Value::GeneratedEmbedding(value) => format!("{:?}", value.vector),
18162        Value::Decimal(value) => value.to_string(),
18163        Value::Interval {
18164            months,
18165            days,
18166            nanos,
18167        } => format!("{months}m {days}d {nanos}ns"),
18168        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
18169        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
18170    }
18171}
18172
18173fn validate_trigger_step<'a>(
18174    step: &TriggerStep,
18175    cat: &'a Catalog,
18176    target_schema: &Schema,
18177    event: TriggerEvent,
18178    select_schemas: &mut HashMap<String, &'a Schema>,
18179) -> Result<()> {
18180    match step {
18181        TriggerStep::SetNew { cells } => {
18182            if event == TriggerEvent::Delete {
18183                return Err(MongrelError::InvalidArgument(
18184                    "SetNew trigger step is not valid for DELETE triggers".into(),
18185                ));
18186            }
18187            for cell in cells {
18188                validate_column_id(cell.column_id, target_schema)?;
18189                validate_trigger_value(&cell.value, target_schema, event)?;
18190            }
18191        }
18192        TriggerStep::Insert { table, cells } => {
18193            let schema = trigger_write_schema(cat, table, "insert")?;
18194            for cell in cells {
18195                validate_column_id(cell.column_id, schema)?;
18196                validate_trigger_value(&cell.value, target_schema, event)?;
18197            }
18198        }
18199        TriggerStep::UpdateByPk { table, pk, cells } => {
18200            let schema = trigger_write_schema(cat, table, "update")?;
18201            if schema.primary_key().is_none() {
18202                return Err(MongrelError::InvalidArgument(format!(
18203                    "trigger update_by_pk references table {table:?} without a primary key"
18204                )));
18205            }
18206            validate_trigger_value(pk, target_schema, event)?;
18207            for cell in cells {
18208                validate_column_id(cell.column_id, schema)?;
18209                validate_trigger_value(&cell.value, target_schema, event)?;
18210            }
18211        }
18212        TriggerStep::DeleteByPk { table, pk } => {
18213            let schema = trigger_write_schema(cat, table, "delete")?;
18214            if schema.primary_key().is_none() {
18215                return Err(MongrelError::InvalidArgument(format!(
18216                    "trigger delete_by_pk references table {table:?} without a primary key"
18217                )));
18218            }
18219            validate_trigger_value(pk, target_schema, event)?;
18220        }
18221        TriggerStep::Select {
18222            id,
18223            table,
18224            conditions,
18225        } => {
18226            let schema = trigger_read_schema(cat, table)?;
18227            for condition in conditions {
18228                validate_trigger_condition(condition, schema, target_schema, event)?;
18229            }
18230            if select_schemas.contains_key(id) {
18231                return Err(MongrelError::InvalidArgument(format!(
18232                    "duplicate select id {id:?} in trigger program"
18233                )));
18234            }
18235            select_schemas.insert(id.clone(), schema);
18236        }
18237        TriggerStep::Foreach { id, steps } => {
18238            if !select_schemas.contains_key(id) {
18239                return Err(MongrelError::InvalidArgument(format!(
18240                    "foreach references unknown select id {id:?}"
18241                )));
18242            }
18243            let mut inner_select_schemas = select_schemas.clone();
18244            for step in steps {
18245                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
18246            }
18247        }
18248        TriggerStep::DeleteWhere { table, conditions } => {
18249            let schema = trigger_write_schema(cat, table, "delete")?;
18250            for condition in conditions {
18251                validate_trigger_condition(condition, schema, target_schema, event)?;
18252            }
18253        }
18254        TriggerStep::UpdateWhere {
18255            table,
18256            conditions,
18257            cells,
18258        } => {
18259            let schema = trigger_write_schema(cat, table, "update")?;
18260            for condition in conditions {
18261                validate_trigger_condition(condition, schema, target_schema, event)?;
18262            }
18263            for cell in cells {
18264                validate_column_id(cell.column_id, schema)?;
18265                validate_trigger_value(&cell.value, target_schema, event)?;
18266            }
18267        }
18268        TriggerStep::Raise { message, .. } => {
18269            validate_trigger_value(message, target_schema, event)?
18270        }
18271    }
18272    Ok(())
18273}
18274
18275fn trigger_validation_error(error: MongrelError) -> MongrelError {
18276    match error {
18277        MongrelError::TriggerValidation(_) => error,
18278        MongrelError::InvalidArgument(message)
18279        | MongrelError::Conflict(message)
18280        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
18281        error => error,
18282    }
18283}
18284
18285fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
18286    if let Some(entry) = cat.live(table) {
18287        return Ok(&entry.schema);
18288    }
18289    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18290        let allowed = match op {
18291            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
18292            "update" | "delete" => entry.capabilities.writable,
18293            _ => false,
18294        };
18295        if !allowed {
18296            return Err(MongrelError::InvalidArgument(format!(
18297                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
18298                entry.module
18299            )));
18300        }
18301        if !entry.capabilities.transaction_safe {
18302            return Err(MongrelError::InvalidArgument(format!(
18303                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
18304                entry.module
18305            )));
18306        }
18307        return Ok(&entry.declared_schema);
18308    }
18309    Err(MongrelError::InvalidArgument(format!(
18310        "trigger references unknown table {table:?}"
18311    )))
18312}
18313
18314fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
18315    if let Some(entry) = cat.live(table) {
18316        return Ok(&entry.schema);
18317    }
18318    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18319        if entry.capabilities.trigger_safe {
18320            return Ok(&entry.declared_schema);
18321        }
18322        return Err(MongrelError::InvalidArgument(format!(
18323            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
18324            entry.module
18325        )));
18326    }
18327    Err(MongrelError::InvalidArgument(format!(
18328        "trigger references unknown table {table:?}"
18329    )))
18330}
18331
18332fn validate_trigger_condition(
18333    condition: &TriggerCondition,
18334    schema: &Schema,
18335    target_schema: &Schema,
18336    event: TriggerEvent,
18337) -> Result<()> {
18338    match condition {
18339        TriggerCondition::Pk { value } => {
18340            if schema.primary_key().is_none() {
18341                return Err(MongrelError::InvalidArgument(
18342                    "trigger condition Pk references a table without a primary key".into(),
18343                ));
18344            }
18345            validate_trigger_value(value, target_schema, event)
18346        }
18347        TriggerCondition::Eq { column_id, value }
18348        | TriggerCondition::NotEq { column_id, value }
18349        | TriggerCondition::Lt { column_id, value }
18350        | TriggerCondition::Lte { column_id, value }
18351        | TriggerCondition::Gt { column_id, value }
18352        | TriggerCondition::Gte { column_id, value } => {
18353            validate_column_id(*column_id, schema)?;
18354            validate_trigger_value(value, target_schema, event)
18355        }
18356        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
18357            validate_column_id(*column_id, schema)
18358        }
18359        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
18360            validate_trigger_condition(left, schema, target_schema, event)?;
18361            validate_trigger_condition(right, schema, target_schema, event)
18362        }
18363        TriggerCondition::Not(condition) => {
18364            validate_trigger_condition(condition, schema, target_schema, event)
18365        }
18366    }
18367}
18368
18369fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
18370    match expr {
18371        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
18372            validate_trigger_value(value, schema, event)
18373        }
18374        TriggerExpr::Eq { left, right }
18375        | TriggerExpr::NotEq { left, right }
18376        | TriggerExpr::Lt { left, right }
18377        | TriggerExpr::Lte { left, right }
18378        | TriggerExpr::Gt { left, right }
18379        | TriggerExpr::Gte { left, right } => {
18380            validate_trigger_value(left, schema, event)?;
18381            validate_trigger_value(right, schema, event)
18382        }
18383        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
18384            validate_trigger_expr(left, schema, event)?;
18385            validate_trigger_expr(right, schema, event)
18386        }
18387        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
18388    }
18389}
18390
18391fn validate_trigger_value(
18392    value: &TriggerValue,
18393    schema: &Schema,
18394    event: TriggerEvent,
18395) -> Result<()> {
18396    match value {
18397        TriggerValue::Literal(_) => Ok(()),
18398        TriggerValue::NewColumn(id) => {
18399            if event == TriggerEvent::Delete {
18400                return Err(MongrelError::InvalidArgument(
18401                    "DELETE triggers cannot reference NEW".into(),
18402                ));
18403            }
18404            validate_column_id(*id, schema)
18405        }
18406        TriggerValue::OldColumn(id) => {
18407            if event == TriggerEvent::Insert {
18408                return Err(MongrelError::InvalidArgument(
18409                    "INSERT triggers cannot reference OLD".into(),
18410                ));
18411            }
18412            validate_column_id(*id, schema)
18413        }
18414        // SELECTED column references are only meaningful inside a foreach loop.
18415        // Strict loop-scope validation is deferred to runtime; the executor raises
18416        // an error if a selected row is not available.
18417        TriggerValue::SelectedColumn(_) => Ok(()),
18418    }
18419}
18420
18421/// Bound on the retained tail of the per-open commit-timestamp ledger
18422/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
18423/// needs recent epochs, so the map keeps the newest commits and drops the
18424/// rest (a miss falls back to a fresh-begin HLC at the caller).
18425const COMMIT_TS_LEDGER_CAP: usize = 10_000;
18426
18427/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
18428/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
18429/// and the `Op::CommitTimestamp` record written ahead of it carries the
18430/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
18431/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
18432/// micros only (same constraint [`crate::commit_log`] documents for replayed
18433/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
18434fn commit_ts_ledger_from_recovery(
18435    records: &[crate::wal::Record],
18436) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
18437    use crate::wal::Op;
18438    let mut commits = HashMap::new();
18439    let mut timestamps = HashMap::new();
18440    for record in records {
18441        match &record.op {
18442            Op::TxnCommit { epoch, .. } => {
18443                commits.insert(record.txn_id, *epoch);
18444            }
18445            Op::CommitTimestamp { unix_nanos } => {
18446                timestamps.insert(record.txn_id, *unix_nanos);
18447            }
18448            _ => {}
18449        }
18450    }
18451    let mut ledger = std::collections::BTreeMap::new();
18452    for (txn_id, epoch) in commits {
18453        let Some(unix_nanos) = timestamps.get(&txn_id) else {
18454            continue;
18455        };
18456        ledger.insert(
18457            epoch,
18458            mongreldb_types::hlc::HlcTimestamp {
18459                physical_micros: unix_nanos / 1_000,
18460                logical: 0,
18461                node_tiebreaker: 0,
18462            },
18463        );
18464    }
18465    while ledger.len() > COMMIT_TS_LEDGER_CAP {
18466        ledger.pop_first();
18467    }
18468    ledger
18469}
18470
18471/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
18472/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
18473/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
18474/// catalog. This pass closes that window by reconstructing missing entries
18475/// (and marking committed drops) before tables are mounted.
18476fn recover_ddl_from_wal(
18477    root: &Path,
18478    durable_root: Option<&crate::durable_file::DurableRoot>,
18479    target_catalog: &mut Catalog,
18480    meta_dek: Option<&[u8; META_DEK_LEN]>,
18481    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
18482    apply: bool,
18483    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18484) -> Result<()> {
18485    use crate::wal::SharedWal;
18486    let records = match durable_root {
18487        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
18488        None => SharedWal::replay_with_dek(root, wal_dek)?,
18489    };
18490    recover_ddl_from_records(
18491        root,
18492        durable_root,
18493        target_catalog,
18494        meta_dek,
18495        apply,
18496        table_roots,
18497        &records,
18498    )
18499}
18500
18501fn recover_ddl_from_records(
18502    root: &Path,
18503    durable_root: Option<&crate::durable_file::DurableRoot>,
18504    target_catalog: &mut Catalog,
18505    meta_dek: Option<&[u8; META_DEK_LEN]>,
18506    apply: bool,
18507    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18508    records: &[crate::wal::Record],
18509) -> Result<()> {
18510    use crate::wal::{DdlOp, Op};
18511
18512    let original_catalog = target_catalog.clone();
18513    let mut recovered_catalog = original_catalog.clone();
18514    let cat = &mut recovered_catalog;
18515    let mut created_table_ids = HashSet::<u64>::new();
18516    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
18517
18518    let mut committed: HashMap<u64, u64> = HashMap::new();
18519    for r in records {
18520        if let Op::TxnCommit { epoch: ce, .. } = r.op {
18521            committed.insert(r.txn_id, ce);
18522        }
18523    }
18524    let catalog_snapshot_txns = records
18525        .iter()
18526        .filter_map(|record| {
18527            (committed.contains_key(&record.txn_id)
18528                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
18529            .then_some(record.txn_id)
18530        })
18531        .collect::<HashSet<_>>();
18532
18533    let mut changed = false;
18534    let mut applied_catalog_epoch = cat.db_epoch;
18535    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
18536    for r in records.iter().cloned() {
18537        let Some(&ce) = committed.get(&r.txn_id) else {
18538            continue;
18539        };
18540        let txn_id = r.txn_id;
18541        match r.op {
18542            Op::Ddl(DdlOp::CreateTable {
18543                table_id,
18544                ref name,
18545                ref schema_json,
18546            }) => {
18547                if cat.tables.iter().any(|t| t.table_id == table_id) {
18548                    continue;
18549                }
18550                let schema = DdlOp::decode_schema(schema_json)?;
18551                validate_recovered_schema(&schema)?;
18552                created_table_ids.insert(table_id);
18553                cat.tables.push(CatalogEntry {
18554                    table_id,
18555                    name: name.clone(),
18556                    schema,
18557                    state: TableState::Live,
18558                    created_epoch: ce,
18559                });
18560                cat.next_table_id =
18561                    cat.next_table_id
18562                        .max(table_id.checked_add(1).ok_or_else(|| {
18563                            MongrelError::Full("table id namespace exhausted".into())
18564                        })?);
18565                changed = true;
18566            }
18567            Op::Ddl(DdlOp::CreateBuildingTable {
18568                table_id,
18569                ref build_name,
18570                ref intended_name,
18571                ref query_id,
18572                created_at_unix_nanos,
18573                ref schema_json,
18574            }) => {
18575                if cat.tables.iter().any(|table| table.table_id == table_id) {
18576                    continue;
18577                }
18578                let schema = DdlOp::decode_schema(schema_json)?;
18579                validate_recovered_schema(&schema)?;
18580                created_table_ids.insert(table_id);
18581                cat.tables.push(CatalogEntry {
18582                    table_id,
18583                    name: build_name.clone(),
18584                    schema,
18585                    state: TableState::Building {
18586                        intended_name: intended_name.clone(),
18587                        query_id: query_id.clone(),
18588                        created_at_unix_nanos,
18589                        replaces_table_id: None,
18590                    },
18591                    created_epoch: ce,
18592                });
18593                cat.next_table_id =
18594                    cat.next_table_id
18595                        .max(table_id.checked_add(1).ok_or_else(|| {
18596                            MongrelError::Full("table id namespace exhausted".into())
18597                        })?);
18598                changed = true;
18599            }
18600            Op::Ddl(DdlOp::CreateRebuildingTable {
18601                table_id,
18602                ref build_name,
18603                ref intended_name,
18604                ref query_id,
18605                created_at_unix_nanos,
18606                replaces_table_id,
18607                ref schema_json,
18608            }) => {
18609                if cat.tables.iter().any(|table| table.table_id == table_id) {
18610                    continue;
18611                }
18612                let schema = DdlOp::decode_schema(schema_json)?;
18613                validate_recovered_schema(&schema)?;
18614                created_table_ids.insert(table_id);
18615                cat.tables.push(CatalogEntry {
18616                    table_id,
18617                    name: build_name.clone(),
18618                    schema,
18619                    state: TableState::Building {
18620                        intended_name: intended_name.clone(),
18621                        query_id: query_id.clone(),
18622                        created_at_unix_nanos,
18623                        replaces_table_id: Some(replaces_table_id),
18624                    },
18625                    created_epoch: ce,
18626                });
18627                cat.next_table_id =
18628                    cat.next_table_id
18629                        .max(table_id.checked_add(1).ok_or_else(|| {
18630                            MongrelError::Full("table id namespace exhausted".into())
18631                        })?);
18632                changed = true;
18633            }
18634            Op::Ddl(DdlOp::DropTable { table_id }) => {
18635                let mut dropped_name = None;
18636                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18637                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18638                        dropped_name = Some(entry.name.clone());
18639                        entry.state = TableState::Dropped { at_epoch: ce };
18640                        changed = true;
18641                    }
18642                }
18643                if let Some(name) = dropped_name {
18644                    let before = cat.materialized_views.len();
18645                    cat.materialized_views
18646                        .retain(|definition| definition.name != name);
18647                    changed |= before != cat.materialized_views.len();
18648                    cat.security.rls_tables.retain(|table| table != &name);
18649                    cat.security.policies.retain(|policy| policy.table != name);
18650                    cat.security.masks.retain(|mask| mask.table != name);
18651                    for role in &mut cat.roles {
18652                        role.permissions
18653                            .retain(|permission| permission_table(permission) != Some(&name));
18654                    }
18655                    if !catalog_snapshot_txns.contains(&txn_id) {
18656                        advance_security_version(cat)?;
18657                    }
18658                }
18659            }
18660            Op::Ddl(DdlOp::PublishBuildingTable {
18661                table_id,
18662                ref new_name,
18663            }) => {
18664                if let Some(entry) = cat
18665                    .tables
18666                    .iter_mut()
18667                    .find(|table| table.table_id == table_id)
18668                {
18669                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
18670                        entry.name = new_name.clone();
18671                        entry.state = TableState::Live;
18672                        changed = true;
18673                    }
18674                }
18675            }
18676            Op::Ddl(DdlOp::ReplaceBuildingTable {
18677                table_id,
18678                replaced_table_id,
18679                ref new_name,
18680            }) => {
18681                changed |=
18682                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
18683            }
18684            Op::Ddl(DdlOp::RenameTable {
18685                table_id,
18686                ref new_name,
18687            }) => {
18688                let mut old_name = None;
18689                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18690                    if entry.name != *new_name {
18691                        old_name = Some(entry.name.clone());
18692                        entry.name = new_name.clone();
18693                        changed = true;
18694                    }
18695                }
18696                if let Some(old_name) = old_name {
18697                    if let Some(definition) = cat
18698                        .materialized_views
18699                        .iter_mut()
18700                        .find(|definition| definition.name == old_name)
18701                    {
18702                        definition.name = new_name.clone();
18703                    }
18704                    for table in &mut cat.security.rls_tables {
18705                        if *table == old_name {
18706                            *table = new_name.clone();
18707                        }
18708                    }
18709                    for policy in &mut cat.security.policies {
18710                        if policy.table == old_name {
18711                            policy.table = new_name.clone();
18712                        }
18713                    }
18714                    for mask in &mut cat.security.masks {
18715                        if mask.table == old_name {
18716                            mask.table = new_name.clone();
18717                        }
18718                    }
18719                    for role in &mut cat.roles {
18720                        for permission in &mut role.permissions {
18721                            rename_permission_table(permission, &old_name, new_name);
18722                        }
18723                    }
18724                    if !catalog_snapshot_txns.contains(&txn_id) {
18725                        advance_security_version(cat)?;
18726                    }
18727                }
18728                // If the entry is absent, its CreateTable was already
18729                // checkpointed carrying the post-rename name, so there is
18730                // nothing to apply — a no-op, not an error.
18731            }
18732            Op::Ddl(DdlOp::AlterTable {
18733                table_id,
18734                ref column_json,
18735            }) => {
18736                let column = DdlOp::decode_column(column_json)?;
18737                let mut renamed = None;
18738                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18739                    renamed = entry
18740                        .schema
18741                        .columns
18742                        .iter()
18743                        .find(|existing| existing.id == column.id && existing.name != column.name)
18744                        .map(|existing| {
18745                            (
18746                                entry.name.clone(),
18747                                existing.name.clone(),
18748                                column.name.clone(),
18749                            )
18750                        });
18751                    if apply_recovered_column_def(&mut entry.schema, column)? {
18752                        validate_recovered_schema(&entry.schema)?;
18753                        changed = true;
18754                    }
18755                }
18756                if let Some((table, old_name, new_name)) = renamed {
18757                    for role in &mut cat.roles {
18758                        for permission in &mut role.permissions {
18759                            rename_permission_column(permission, &table, &old_name, &new_name);
18760                        }
18761                    }
18762                    if !catalog_snapshot_txns.contains(&txn_id) {
18763                        advance_security_version(cat)?;
18764                    }
18765                }
18766            }
18767            Op::Ddl(DdlOp::SetTtl {
18768                table_id,
18769                ref policy_json,
18770            }) => {
18771                let policy = DdlOp::decode_ttl(policy_json)?;
18772                let entry = cat
18773                    .tables
18774                    .iter()
18775                    .find(|entry| entry.table_id == table_id)
18776                    .ok_or_else(|| {
18777                        MongrelError::Schema(format!(
18778                            "recovered TTL references unknown table id {table_id}"
18779                        ))
18780                    })?;
18781                if let Some(policy) = policy {
18782                    let valid = entry
18783                        .schema
18784                        .columns
18785                        .iter()
18786                        .find(|column| column.id == policy.column_id)
18787                        .is_some_and(|column| {
18788                            column.ty == TypeId::TimestampNanos
18789                                && policy.duration_nanos > 0
18790                                && policy.duration_nanos <= i64::MAX as u64
18791                        });
18792                    if !valid {
18793                        return Err(MongrelError::Schema(format!(
18794                            "invalid recovered TTL policy for table id {table_id}"
18795                        )));
18796                    }
18797                }
18798                ttl_updates.insert(table_id, (policy, ce));
18799            }
18800            Op::Ddl(DdlOp::SetMaterializedView {
18801                ref name,
18802                ref definition_json,
18803            }) => {
18804                let definition = DdlOp::decode_materialized_view(definition_json)?;
18805                if definition.name != *name {
18806                    return Err(MongrelError::Schema(format!(
18807                        "materialized view WAL name mismatch: {name:?}"
18808                    )));
18809                }
18810                if cat.live(name).is_some() {
18811                    if let Some(existing) = cat
18812                        .materialized_views
18813                        .iter_mut()
18814                        .find(|existing| existing.name == *name)
18815                    {
18816                        if *existing != definition {
18817                            *existing = definition;
18818                            changed = true;
18819                        }
18820                    } else {
18821                        cat.materialized_views.push(definition);
18822                        changed = true;
18823                    }
18824                }
18825            }
18826            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
18827                let security = DdlOp::decode_security(security_json)?;
18828                validate_security_catalog(cat, &security)?;
18829                if cat.security != security {
18830                    cat.security = security;
18831                    if !catalog_snapshot_txns.contains(&txn_id) {
18832                        advance_security_version(cat)?;
18833                    }
18834                    changed = true;
18835                }
18836            }
18837            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
18838                let target = match key.as_str() {
18839                    "user_version" => &mut cat.user_version,
18840                    "application_id" => &mut cat.application_id,
18841                    _ => {
18842                        return Err(MongrelError::InvalidArgument(format!(
18843                            "unsupported recovered SQL pragma {key:?}"
18844                        )))
18845                    }
18846                };
18847                if *target != Some(value) {
18848                    *target = Some(value);
18849                    cat.db_epoch = cat.db_epoch.max(ce);
18850                    changed = true;
18851                }
18852            }
18853            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
18854                if ce <= applied_catalog_epoch {
18855                    continue;
18856                }
18857                let snapshot = DdlOp::decode_catalog(catalog_json)?;
18858                if snapshot.db_epoch != ce {
18859                    return Err(MongrelError::Schema(format!(
18860                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
18861                        snapshot.db_epoch
18862                    )));
18863                }
18864                validate_recovered_catalog(&snapshot)?;
18865                validate_catalog_transition(cat, &snapshot)?;
18866                *cat = snapshot;
18867                applied_catalog_epoch = ce;
18868                changed = true;
18869            }
18870            _ => {}
18871        }
18872    }
18873
18874    if cat.db_epoch < max_committed_epoch {
18875        cat.db_epoch = max_committed_epoch;
18876        changed = true;
18877    }
18878    changed |= repair_catalog_allocator_counters(cat)?;
18879
18880    validate_recovered_catalog(cat)?;
18881    let storage_reconciliation = validate_recovered_storage_plan(
18882        root,
18883        durable_root,
18884        cat,
18885        &created_table_ids,
18886        &ttl_updates,
18887        meta_dek,
18888    )?;
18889
18890    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
18891    if apply && (changed || needs_storage_apply) {
18892        for table_id in storage_reconciliation {
18893            let entry = cat
18894                .tables
18895                .iter()
18896                .find(|entry| entry.table_id == table_id)
18897                .ok_or_else(|| MongrelError::CorruptWal {
18898                    offset: table_id,
18899                    reason: "recovery storage plan lost its catalog table".into(),
18900                })?;
18901            ensure_recovered_table_storage(
18902                table_roots
18903                    .and_then(|roots| roots.get(&table_id))
18904                    .map(Arc::as_ref),
18905                durable_root,
18906                &root.join(TABLES_DIR).join(table_id.to_string()),
18907                table_id,
18908                &entry.schema,
18909                meta_dek,
18910            )?;
18911        }
18912        for (table_id, (policy, ttl_epoch)) in ttl_updates {
18913            let Some(entry) = cat.tables.iter().find(|entry| {
18914                entry.table_id == table_id
18915                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
18916            }) else {
18917                continue;
18918            };
18919            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
18920            {
18921                root.try_clone()?
18922            } else if let Some(root) = durable_root {
18923                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
18924            } else {
18925                crate::durable_file::DurableRoot::open(
18926                    root.join(TABLES_DIR).join(table_id.to_string()),
18927                )?
18928            };
18929            let table_dir = table_root.io_path()?;
18930            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
18931            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
18932                manifest.ttl = policy;
18933                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
18934                manifest.schema_id = entry.schema.schema_id;
18935                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18936            }
18937        }
18938        if changed {
18939            match durable_root {
18940                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
18941                None => catalog::write_atomic(root, cat, meta_dek)?,
18942            }
18943        }
18944    }
18945    *target_catalog = recovered_catalog;
18946    Ok(())
18947}
18948
18949fn ensure_recovered_table_storage(
18950    pinned_table: Option<&crate::durable_file::DurableRoot>,
18951    durable_root: Option<&crate::durable_file::DurableRoot>,
18952    fallback_table_dir: &Path,
18953    table_id: u64,
18954    schema: &Schema,
18955    meta_dek: Option<&[u8; META_DEK_LEN]>,
18956) -> Result<()> {
18957    let table_root = if let Some(root) = pinned_table {
18958        root.try_clone()?
18959    } else if let Some(root) = durable_root {
18960        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
18961        match root.open_directory(&relative) {
18962            Ok(table) => table,
18963            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
18964                root.create_directory_all_pinned(relative)?
18965            }
18966            Err(error) => return Err(error.into()),
18967        }
18968    } else {
18969        crate::durable_file::create_directory_all(fallback_table_dir)?;
18970        crate::durable_file::DurableRoot::open(fallback_table_dir)?
18971    };
18972    let table_dir = table_root.io_path()?;
18973    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
18974        Ok(manifest) => {
18975            if manifest.table_id != table_id {
18976                return Err(MongrelError::Conflict(format!(
18977                    "recovered table directory id mismatch: expected {table_id}, found {}",
18978                    manifest.table_id
18979                )));
18980            }
18981            Some(manifest)
18982        }
18983        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
18984        Err(error) => return Err(error),
18985    };
18986
18987    table_root.create_directory_all(crate::engine::WAL_DIR)?;
18988    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
18989    crate::engine::write_schema(&table_dir, schema)?;
18990
18991    if let Some(mut manifest) = existing_manifest.take() {
18992        if manifest.schema_id != schema.schema_id {
18993            manifest.schema_id = schema.schema_id;
18994            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18995        }
18996    } else {
18997        // The DB-wide meta DEK is also the per-table manifest meta DEK.
18998        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
18999        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
19000    }
19001    Ok(())
19002}
19003
19004fn validate_recovered_schema(schema: &Schema) -> Result<()> {
19005    schema.validate_auto_increment()?;
19006    schema.validate_defaults()?;
19007    schema.validate_ai()?;
19008    let mut column_ids = HashSet::new();
19009    let mut column_names = HashSet::new();
19010    for column in &schema.columns {
19011        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
19012            return Err(MongrelError::Schema(
19013                "recovered schema contains duplicate columns".into(),
19014            ));
19015        }
19016        match &column.ty {
19017            TypeId::Decimal128 { precision, scale }
19018                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
19019            {
19020                return Err(MongrelError::Schema(format!(
19021                    "column {:?} has invalid decimal precision or scale",
19022                    column.name
19023                )));
19024            }
19025            TypeId::Enum { variants }
19026                if variants.is_empty()
19027                    || variants.iter().any(String::is_empty)
19028                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
19029            {
19030                return Err(MongrelError::Schema(format!(
19031                    "column {:?} has invalid enum variants",
19032                    column.name
19033                )));
19034            }
19035            _ => {}
19036        }
19037    }
19038    let mut index_names = HashSet::new();
19039    for index in &schema.indexes {
19040        index.validate_options()?;
19041        if index.name.is_empty()
19042            || !index_names.insert(index.name.as_str())
19043            || schema
19044                .columns
19045                .iter()
19046                .all(|column| column.id != index.column_id)
19047        {
19048            return Err(MongrelError::Schema(format!(
19049                "recovered index {:?} references missing column {}",
19050                index.name, index.column_id
19051            )));
19052        }
19053    }
19054    let mut colocated = HashSet::new();
19055    for group in &schema.colocation {
19056        if group.is_empty()
19057            || group.iter().any(|id| !column_ids.contains(id))
19058            || group.iter().any(|id| !colocated.insert(*id))
19059        {
19060            return Err(MongrelError::Schema(
19061                "recovered schema contains invalid column co-location groups".into(),
19062            ));
19063        }
19064    }
19065
19066    let mut constraint_ids = HashSet::new();
19067    let mut constraint_names = HashSet::<String>::new();
19068    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
19069        if name.is_empty()
19070            || !constraint_ids.insert(id)
19071            || !constraint_names.insert(name.to_owned())
19072        {
19073            return Err(MongrelError::Schema(
19074                "recovered schema contains duplicate or empty constraint identities".into(),
19075            ));
19076        }
19077        Ok(())
19078    };
19079    for unique in &schema.constraints.uniques {
19080        validate_constraint_identity(unique.id, &unique.name)?;
19081        if unique.columns.is_empty()
19082            || unique.columns.iter().any(|id| !column_ids.contains(id))
19083            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
19084        {
19085            return Err(MongrelError::Schema(format!(
19086                "unique constraint {:?} has invalid columns",
19087                unique.name
19088            )));
19089        }
19090    }
19091    for foreign_key in &schema.constraints.foreign_keys {
19092        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
19093        if foreign_key.ref_table.is_empty()
19094            || foreign_key.columns.is_empty()
19095            || foreign_key.columns.len() != foreign_key.ref_columns.len()
19096            || foreign_key
19097                .columns
19098                .iter()
19099                .any(|id| !column_ids.contains(id))
19100            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
19101            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
19102                != foreign_key.ref_columns.len()
19103        {
19104            return Err(MongrelError::Schema(format!(
19105                "foreign key {:?} has invalid columns",
19106                foreign_key.name
19107            )));
19108        }
19109        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
19110            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
19111            && foreign_key.columns.iter().any(|id| {
19112                schema
19113                    .columns
19114                    .iter()
19115                    .find(|column| column.id == *id)
19116                    .is_none_or(|column| {
19117                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
19118                    })
19119            })
19120        {
19121            return Err(MongrelError::Schema(format!(
19122                "foreign key {:?} uses SET NULL on a non-nullable column",
19123                foreign_key.name
19124            )));
19125        }
19126    }
19127    for check in &schema.constraints.checks {
19128        validate_constraint_identity(check.id, &check.name)?;
19129        check.expr.validate()?;
19130        validate_check_columns(&check.expr, &column_ids)?;
19131    }
19132    Ok(())
19133}
19134
19135fn validate_check_columns(
19136    expression: &crate::constraint::CheckExpr,
19137    column_ids: &HashSet<u16>,
19138) -> Result<()> {
19139    use crate::constraint::CheckExpr;
19140    match expression {
19141        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
19142            if column_ids.contains(id) {
19143                Ok(())
19144            } else {
19145                Err(MongrelError::Schema(format!(
19146                    "check constraint references unknown column {id}"
19147                )))
19148            }
19149        }
19150        CheckExpr::Regex { col, .. } => {
19151            if column_ids.contains(col) {
19152                Ok(())
19153            } else {
19154                Err(MongrelError::Schema(format!(
19155                    "check constraint references unknown column {col}"
19156                )))
19157            }
19158        }
19159        CheckExpr::Add(left, right)
19160        | CheckExpr::Sub(left, right)
19161        | CheckExpr::Mul(left, right)
19162        | CheckExpr::Div(left, right)
19163        | CheckExpr::Mod(left, right)
19164        | CheckExpr::Eq(left, right)
19165        | CheckExpr::Ne(left, right)
19166        | CheckExpr::Lt(left, right)
19167        | CheckExpr::Le(left, right)
19168        | CheckExpr::Gt(left, right)
19169        | CheckExpr::Ge(left, right)
19170        | CheckExpr::And(left, right)
19171        | CheckExpr::Or(left, right) => {
19172            validate_check_columns(left, column_ids)?;
19173            validate_check_columns(right, column_ids)
19174        }
19175        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
19176        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
19177    }
19178}
19179
19180fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
19181    for (name, prior, candidate) in [
19182        ("db_epoch", current.db_epoch, next.db_epoch),
19183        ("next_table_id", current.next_table_id, next.next_table_id),
19184        (
19185            "next_segment_no",
19186            current.next_segment_no,
19187            next.next_segment_no,
19188        ),
19189        ("next_user_id", current.next_user_id, next.next_user_id),
19190        (
19191            "security_version",
19192            current.security_version,
19193            next.security_version,
19194        ),
19195    ] {
19196        if candidate < prior {
19197            return Err(MongrelError::Schema(format!(
19198                "catalog snapshot rolls back {name} from {prior} to {candidate}"
19199            )));
19200        }
19201    }
19202    for prior in &current.tables {
19203        let Some(candidate) = next
19204            .tables
19205            .iter()
19206            .find(|entry| entry.table_id == prior.table_id)
19207        else {
19208            return Err(MongrelError::Schema(format!(
19209                "catalog snapshot removes table identity {}",
19210                prior.table_id
19211            )));
19212        };
19213        if candidate.created_epoch != prior.created_epoch
19214            || candidate.schema.schema_id < prior.schema.schema_id
19215            || matches!(prior.state, TableState::Dropped { .. })
19216                && !matches!(candidate.state, TableState::Dropped { .. })
19217        {
19218            return Err(MongrelError::Schema(format!(
19219                "catalog snapshot rolls back table identity {}",
19220                prior.table_id
19221            )));
19222        }
19223    }
19224    for prior in &current.users {
19225        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
19226            if candidate.username != prior.username
19227                || candidate.created_epoch != prior.created_epoch
19228            {
19229                return Err(MongrelError::Schema(format!(
19230                    "catalog snapshot reuses user identity {}",
19231                    prior.id
19232                )));
19233            }
19234        }
19235    }
19236    Ok(())
19237}
19238
19239fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
19240    let mut table_ids = HashSet::new();
19241    let mut active_names = HashSet::new();
19242    let mut max_table_id = None::<u64>;
19243    for entry in &catalog.tables {
19244        if !table_ids.insert(entry.table_id) {
19245            return Err(MongrelError::Schema(format!(
19246                "catalog contains duplicate table id {}",
19247                entry.table_id
19248            )));
19249        }
19250        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
19251        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
19252            return Err(MongrelError::Schema(format!(
19253                "catalog table {} has invalid name or creation epoch",
19254                entry.table_id
19255            )));
19256        }
19257        validate_recovered_schema(&entry.schema)?;
19258        match &entry.state {
19259            TableState::Live => {
19260                if !active_names.insert(entry.name.as_str()) {
19261                    return Err(MongrelError::Schema(format!(
19262                        "catalog contains duplicate active table name {:?}",
19263                        entry.name
19264                    )));
19265                }
19266            }
19267            TableState::Dropped { at_epoch } => {
19268                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
19269                    return Err(MongrelError::Schema(format!(
19270                        "catalog table {} has invalid drop epoch {at_epoch}",
19271                        entry.table_id
19272                    )));
19273                }
19274            }
19275            TableState::Building {
19276                intended_name,
19277                query_id,
19278                replaces_table_id,
19279                ..
19280            } => {
19281                if intended_name.is_empty() || query_id.is_empty() {
19282                    return Err(MongrelError::Schema(format!(
19283                        "building table {} has empty identity fields",
19284                        entry.table_id
19285                    )));
19286                }
19287                if !active_names.insert(entry.name.as_str()) {
19288                    return Err(MongrelError::Schema(format!(
19289                        "catalog contains duplicate active/building table name {:?}",
19290                        entry.name
19291                    )));
19292                }
19293                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
19294                    return Err(MongrelError::Schema(
19295                        "building table cannot replace itself".into(),
19296                    ));
19297                }
19298            }
19299        }
19300    }
19301    if let Some(maximum) = max_table_id {
19302        let required = maximum
19303            .checked_add(1)
19304            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19305        if catalog.next_table_id < required {
19306            return Err(MongrelError::Schema(format!(
19307                "catalog next_table_id {} precedes required {required}",
19308                catalog.next_table_id
19309            )));
19310        }
19311    }
19312    for entry in &catalog.tables {
19313        if let TableState::Building {
19314            replaces_table_id: Some(replaced),
19315            ..
19316        } = entry.state
19317        {
19318            if !table_ids.contains(&replaced) {
19319                return Err(MongrelError::Schema(format!(
19320                    "building table {} replaces unknown table {replaced}",
19321                    entry.table_id
19322                )));
19323            }
19324        }
19325    }
19326    for entry in &catalog.tables {
19327        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19328            validate_foreign_key_targets(catalog, &entry.schema)?;
19329        }
19330    }
19331
19332    let mut external_names = HashSet::new();
19333    for entry in &catalog.external_tables {
19334        entry.validate()?;
19335        validate_recovered_schema(&entry.declared_schema)?;
19336        if !entry.declared_schema.constraints.is_empty() {
19337            return Err(MongrelError::Schema(format!(
19338                "external table {:?} cannot carry engine-enforced constraints",
19339                entry.name
19340            )));
19341        }
19342        if entry.created_epoch > catalog.db_epoch
19343            || !external_names.insert(entry.name.as_str())
19344            || active_names.contains(entry.name.as_str())
19345        {
19346            return Err(MongrelError::Schema(format!(
19347                "invalid or duplicate external table {:?}",
19348                entry.name
19349            )));
19350        }
19351    }
19352
19353    let mut procedure_names = HashSet::new();
19354    for entry in &catalog.procedures {
19355        entry.procedure.validate()?;
19356        if entry.procedure.created_epoch > entry.procedure.updated_epoch
19357            || entry.procedure.updated_epoch > catalog.db_epoch
19358            || !procedure_names.insert(entry.procedure.name.as_str())
19359        {
19360            return Err(MongrelError::Schema(format!(
19361                "invalid or duplicate procedure {:?}",
19362                entry.procedure.name
19363            )));
19364        }
19365        validate_recovered_procedure_references(catalog, &entry.procedure)?;
19366    }
19367
19368    let mut trigger_names = HashSet::new();
19369    for entry in &catalog.triggers {
19370        entry.trigger.validate()?;
19371        if entry.trigger.created_epoch > entry.trigger.updated_epoch
19372            || entry.trigger.updated_epoch > catalog.db_epoch
19373            || !trigger_names.insert(entry.trigger.name.as_str())
19374        {
19375            return Err(MongrelError::Schema(format!(
19376                "invalid or duplicate trigger {:?}",
19377                entry.trigger.name
19378            )));
19379        }
19380        validate_recovered_trigger_references(catalog, &entry.trigger)?;
19381    }
19382
19383    let mut views = HashSet::new();
19384    for view in &catalog.materialized_views {
19385        let target = catalog.live(&view.name).ok_or_else(|| {
19386            MongrelError::Schema(format!(
19387                "materialized view {:?} has no live table",
19388                view.name
19389            ))
19390        })?;
19391        if view.name.is_empty()
19392            || view.query.trim().is_empty()
19393            || view.last_refresh_epoch > catalog.db_epoch
19394            || !views.insert(view.name.as_str())
19395        {
19396            return Err(MongrelError::Schema(format!(
19397                "materialized view {:?} has no unique live table",
19398                view.name
19399            )));
19400        }
19401        if let Some(incremental) = &view.incremental {
19402            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
19403                MongrelError::Schema(format!(
19404                    "materialized view {:?} references missing source {:?}",
19405                    view.name, incremental.source_table
19406                ))
19407            })?;
19408            if source.table_id != incremental.source_table_id
19409                || source
19410                    .schema
19411                    .columns
19412                    .iter()
19413                    .all(|column| column.id != incremental.group_column)
19414            {
19415                return Err(MongrelError::Schema(format!(
19416                    "materialized view {:?} has invalid incremental source",
19417                    view.name
19418                )));
19419            }
19420            let target_ids = target
19421                .schema
19422                .columns
19423                .iter()
19424                .map(|column| column.id)
19425                .collect::<HashSet<_>>();
19426            let mut output_ids = HashSet::new();
19427            let count_outputs = incremental
19428                .outputs
19429                .iter()
19430                .filter(|output| {
19431                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19432                })
19433                .count();
19434            if incremental.checkpoint_event_id.is_empty()
19435                || !target_ids.contains(&incremental.group_output_column)
19436                || !target_ids.contains(&incremental.count_output_column)
19437                || incremental.outputs.is_empty()
19438                || count_outputs != 1
19439                || incremental.outputs.iter().any(|output| {
19440                    !target_ids.contains(&output.output_column)
19441                        || output.output_column == incremental.group_output_column
19442                        || !output_ids.insert(output.output_column)
19443                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19444                            && output.output_column != incremental.count_output_column
19445                        || match output.kind {
19446                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
19447                                source
19448                                    .schema
19449                                    .columns
19450                                    .iter()
19451                                    .all(|column| column.id != source_column)
19452                            }
19453                            crate::catalog::IncrementalAggregateKind::Count => false,
19454                        }
19455                })
19456            {
19457                return Err(MongrelError::Schema(format!(
19458                    "materialized view {:?} has invalid incremental outputs",
19459                    view.name
19460                )));
19461            }
19462        }
19463    }
19464
19465    validate_security_catalog(catalog, &catalog.security)?;
19466    validate_recovered_auth_catalog(catalog)?;
19467    Ok(())
19468}
19469
19470fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
19471    let mut changed = false;
19472    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
19473        let required = maximum
19474            .checked_add(1)
19475            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19476        if catalog.next_table_id < required {
19477            catalog.next_table_id = required;
19478            changed = true;
19479        }
19480    }
19481    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
19482        let required = maximum
19483            .checked_add(1)
19484            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
19485        if catalog.next_user_id < required {
19486            catalog.next_user_id = required;
19487            changed = true;
19488        }
19489    }
19490    Ok(changed)
19491}
19492
19493fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
19494    for foreign_key in &schema.constraints.foreign_keys {
19495        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
19496            MongrelError::Schema(format!(
19497                "foreign key {:?} references unknown live table {:?}",
19498                foreign_key.name, foreign_key.ref_table
19499            ))
19500        })?;
19501        let referenced_unique = parent
19502            .schema
19503            .constraints
19504            .uniques
19505            .iter()
19506            .any(|unique| unique.columns == foreign_key.ref_columns)
19507            || foreign_key.ref_columns.len() == 1
19508                && parent
19509                    .schema
19510                    .primary_key()
19511                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
19512        if !referenced_unique {
19513            return Err(MongrelError::Schema(format!(
19514                "foreign key {:?} does not reference a unique key",
19515                foreign_key.name
19516            )));
19517        }
19518        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
19519            let local = schema.columns.iter().find(|column| column.id == *local_id);
19520            let referenced = parent
19521                .schema
19522                .columns
19523                .iter()
19524                .find(|column| column.id == *parent_id);
19525            if local
19526                .zip(referenced)
19527                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
19528            {
19529                return Err(MongrelError::Schema(format!(
19530                    "foreign key {:?} has missing or incompatible columns",
19531                    foreign_key.name
19532                )));
19533            }
19534        }
19535    }
19536    Ok(())
19537}
19538
19539fn validate_recovered_procedure_references(
19540    catalog: &Catalog,
19541    procedure: &StoredProcedure,
19542) -> Result<()> {
19543    for step in &procedure.body.steps {
19544        let Some(table_name) = step.table() else {
19545            continue;
19546        };
19547        let schema = &catalog
19548            .live(table_name)
19549            .ok_or_else(|| {
19550                MongrelError::Schema(format!(
19551                    "procedure {:?} references unknown table {table_name:?}",
19552                    procedure.name
19553                ))
19554            })?
19555            .schema;
19556        match step {
19557            ProcedureStep::NativeQuery {
19558                conditions,
19559                projection,
19560                ..
19561            } => {
19562                for condition in conditions {
19563                    validate_condition_columns(condition, schema)?;
19564                }
19565                for id in projection.iter().flatten() {
19566                    validate_column_id(*id, schema)?;
19567                }
19568            }
19569            ProcedureStep::Put { cells, .. } => {
19570                for cell in cells {
19571                    validate_column_id(cell.column_id, schema)?;
19572                }
19573            }
19574            ProcedureStep::Upsert {
19575                cells,
19576                update_cells,
19577                ..
19578            } => {
19579                for cell in cells.iter().chain(update_cells.iter().flatten()) {
19580                    validate_column_id(cell.column_id, schema)?;
19581                }
19582            }
19583            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
19584                return Err(MongrelError::Schema(format!(
19585                    "procedure {:?} deletes by primary key on table without one",
19586                    procedure.name
19587                )));
19588            }
19589            ProcedureStep::DeleteByPk { .. }
19590            | ProcedureStep::DeleteRows { .. }
19591            | ProcedureStep::SqlQuery { .. } => {}
19592        }
19593    }
19594    Ok(())
19595}
19596
19597fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
19598    let target_schema = match &trigger.target {
19599        TriggerTarget::Table(name) => catalog
19600            .live(name)
19601            .ok_or_else(|| {
19602                MongrelError::Schema(format!(
19603                    "trigger {:?} references unknown table {name:?}",
19604                    trigger.name
19605                ))
19606            })?
19607            .schema
19608            .clone(),
19609        TriggerTarget::View(_) => Schema {
19610            columns: trigger.target_columns.clone(),
19611            ..Schema::default()
19612        },
19613    };
19614    for column in &trigger.update_of {
19615        if target_schema.column(column).is_none() {
19616            return Err(MongrelError::Schema(format!(
19617                "trigger {:?} references unknown UPDATE OF column {column:?}",
19618                trigger.name
19619            )));
19620        }
19621    }
19622    if let Some(expr) = &trigger.when {
19623        validate_trigger_expr(expr, &target_schema, trigger.event)?;
19624    }
19625    let mut selects = HashMap::new();
19626    for step in &trigger.program.steps {
19627        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
19628            return Err(MongrelError::Schema(
19629                "SetNew is only valid in BEFORE triggers".into(),
19630            ));
19631        }
19632        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
19633    }
19634    Ok(())
19635}
19636
19637fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
19638    let mut role_names = HashSet::new();
19639    for role in &catalog.roles {
19640        if role.name.is_empty()
19641            || role.created_epoch > catalog.db_epoch
19642            || !role_names.insert(role.name.as_str())
19643        {
19644            return Err(MongrelError::Schema(format!(
19645                "invalid or duplicate role {:?}",
19646                role.name
19647            )));
19648        }
19649        for permission in &role.permissions {
19650            if let Some(table) = permission_table(permission) {
19651                let schema = catalog
19652                    .live(table)
19653                    .map(|entry| &entry.schema)
19654                    .or_else(|| {
19655                        catalog
19656                            .external_tables
19657                            .iter()
19658                            .find(|entry| entry.name == table)
19659                            .map(|entry| &entry.declared_schema)
19660                    })
19661                    .ok_or_else(|| {
19662                        MongrelError::Schema(format!(
19663                            "role {:?} references unknown table {table:?}",
19664                            role.name
19665                        ))
19666                    })?;
19667                let columns = match permission {
19668                    crate::auth::Permission::SelectColumns { columns, .. }
19669                    | crate::auth::Permission::InsertColumns { columns, .. }
19670                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
19671                    _ => None,
19672                };
19673                if columns.is_some_and(|columns| {
19674                    columns.is_empty()
19675                        || columns.iter().any(|column| schema.column(column).is_none())
19676                }) {
19677                    return Err(MongrelError::Schema(format!(
19678                        "role {:?} contains invalid column permissions",
19679                        role.name
19680                    )));
19681                }
19682            }
19683        }
19684    }
19685    let mut user_ids = HashSet::new();
19686    let mut usernames = HashSet::new();
19687    let mut maximum_user_id = 0;
19688    for user in &catalog.users {
19689        maximum_user_id = maximum_user_id.max(user.id);
19690        if user.id == 0
19691            || user.username.is_empty()
19692            || user.password_hash.is_empty()
19693            || user.created_epoch > catalog.db_epoch
19694            || !user_ids.insert(user.id)
19695            || !usernames.insert(user.username.as_str())
19696            || user
19697                .roles
19698                .iter()
19699                .any(|role| !role_names.contains(role.as_str()))
19700        {
19701            return Err(MongrelError::Schema(format!(
19702                "invalid or duplicate user {:?}",
19703                user.username
19704            )));
19705        }
19706    }
19707    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
19708        return Err(MongrelError::Schema(
19709            "catalog next_user_id does not advance beyond existing user ids".into(),
19710        ));
19711    }
19712    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
19713        return Err(MongrelError::Schema(
19714            "authenticated catalog has no administrator".into(),
19715        ));
19716    }
19717    Ok(())
19718}
19719
19720fn validate_recovered_storage_plan(
19721    root: &Path,
19722    durable_root: Option<&crate::durable_file::DurableRoot>,
19723    catalog: &Catalog,
19724    created_table_ids: &HashSet<u64>,
19725    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
19726    meta_dek: Option<&[u8; META_DEK_LEN]>,
19727) -> Result<Vec<u64>> {
19728    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
19729    let mut reconcile = Vec::new();
19730    for entry in &catalog.tables {
19731        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19732            continue;
19733        }
19734        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19735        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
19736        let table_exists = match durable_root {
19737            Some(root) => match root.open_directory(&relative_dir) {
19738                Ok(_) => true,
19739                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19740                Err(error) => return Err(error.into()),
19741            },
19742            None => table_dir.is_dir(),
19743        };
19744        if !table_exists {
19745            if created_table_ids.contains(&entry.table_id) {
19746                reconcile.push(entry.table_id);
19747                continue;
19748            }
19749            return Err(MongrelError::NotFound(format!(
19750                "catalog table {} storage is missing",
19751                entry.table_id
19752            )));
19753        }
19754        let manifest_result = match durable_root {
19755            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
19756            None => crate::manifest::read(&table_dir, meta_dek),
19757        };
19758        let manifest = match manifest_result {
19759            Ok(manifest) => manifest,
19760            Err(MongrelError::Io(error))
19761                if created_table_ids.contains(&entry.table_id)
19762                    && error.kind() == std::io::ErrorKind::NotFound =>
19763            {
19764                reconcile.push(entry.table_id);
19765                continue;
19766            }
19767            Err(error) => return Err(error),
19768        };
19769        if manifest.table_id != entry.table_id {
19770            return Err(MongrelError::Conflict(format!(
19771                "catalog table {} storage identity mismatch",
19772                entry.table_id
19773            )));
19774        }
19775        let schema_result = match durable_root {
19776            Some(root) => root
19777                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
19778                .map_err(MongrelError::from),
19779            None => crate::durable_file::open_regular_nofollow(
19780                &table_dir.join(crate::engine::SCHEMA_FILENAME),
19781            ),
19782        };
19783        let file = match schema_result {
19784            Ok(file) => file,
19785            Err(MongrelError::Io(error))
19786                if created_table_ids.contains(&entry.table_id)
19787                    && error.kind() == std::io::ErrorKind::NotFound =>
19788            {
19789                reconcile.push(entry.table_id);
19790                continue;
19791            }
19792            Err(error) => return Err(error),
19793        };
19794        let length = file.metadata()?.len();
19795        if length > MAX_SCHEMA_BYTES {
19796            return Err(MongrelError::ResourceLimitExceeded {
19797                resource: "recovered schema bytes",
19798                requested: usize::try_from(length).unwrap_or(usize::MAX),
19799                limit: MAX_SCHEMA_BYTES as usize,
19800            });
19801        }
19802        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
19803            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
19804        if manifest.schema_id != entry.schema.schema_id
19805            || crate::wal::DdlOp::encode_schema(&disk_schema)?
19806                != crate::wal::DdlOp::encode_schema(&entry.schema)?
19807        {
19808            reconcile.push(entry.table_id);
19809        }
19810    }
19811    for table_id in ttl_updates.keys() {
19812        if !catalog.tables.iter().any(|entry| {
19813            entry.table_id == *table_id
19814                && matches!(entry.state, TableState::Live | TableState::Building { .. })
19815        }) {
19816            continue;
19817        }
19818        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
19819        let table_exists = match durable_root {
19820            Some(root) => match root.open_directory(&relative_dir) {
19821                Ok(_) => true,
19822                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19823                Err(error) => return Err(error.into()),
19824            },
19825            None => root.join(&relative_dir).is_dir(),
19826        };
19827        if !table_exists && !created_table_ids.contains(table_id) {
19828            return Err(MongrelError::NotFound(format!(
19829                "TTL recovery table {table_id} storage is missing"
19830            )));
19831        }
19832    }
19833    reconcile.sort_unstable();
19834    reconcile.dedup();
19835    Ok(reconcile)
19836}
19837
19838fn validate_catalog_table_storage(
19839    root: &crate::durable_file::DurableRoot,
19840    catalog: &Catalog,
19841    meta_dek: Option<&[u8; META_DEK_LEN]>,
19842) -> Result<()> {
19843    for entry in &catalog.tables {
19844        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19845            continue;
19846        }
19847        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19848        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
19849        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
19850            return Err(MongrelError::Conflict(format!(
19851                "catalog table {} storage identity mismatch",
19852                entry.table_id
19853            )));
19854        }
19855        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
19856    }
19857    Ok(())
19858}
19859
19860fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
19861    match schema.columns.iter_mut().find(|c| c.id == column.id) {
19862        Some(existing) if *existing == column => Ok(false),
19863        Some(existing) => {
19864            *existing = column;
19865            schema.schema_id = schema
19866                .schema_id
19867                .checked_add(1)
19868                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19869            Ok(true)
19870        }
19871        None => {
19872            schema.columns.push(column);
19873            schema.schema_id = schema
19874                .schema_id
19875                .checked_add(1)
19876                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19877            Ok(true)
19878        }
19879    }
19880}
19881
19882fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
19883    use crate::auth::Permission;
19884    match permission {
19885        Permission::Select { table }
19886        | Permission::Insert { table }
19887        | Permission::Update { table }
19888        | Permission::Delete { table }
19889        | Permission::SelectColumns { table, .. }
19890        | Permission::InsertColumns { table, .. }
19891        | Permission::UpdateColumns { table, .. } => Some(table),
19892        Permission::All | Permission::Ddl | Permission::Admin => None,
19893    }
19894}
19895
19896fn apply_rebuilding_publish(
19897    catalog: &mut Catalog,
19898    table_id: u64,
19899    replaced_table_id: u64,
19900    new_name: &str,
19901    epoch: u64,
19902) -> Result<bool> {
19903    let already_published = catalog.tables.iter().any(|entry| {
19904        entry.table_id == table_id
19905            && entry.name == new_name
19906            && matches!(entry.state, TableState::Live)
19907    }) && catalog.tables.iter().any(|entry| {
19908        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
19909    });
19910    if already_published {
19911        return Ok(false);
19912    }
19913    let schema = catalog
19914        .tables
19915        .iter()
19916        .find(|entry| entry.table_id == table_id)
19917        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
19918        .schema
19919        .clone();
19920    let replaced = catalog
19921        .tables
19922        .iter_mut()
19923        .find(|entry| entry.table_id == replaced_table_id)
19924        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
19925    replaced.state = TableState::Dropped { at_epoch: epoch };
19926    let replacement = catalog
19927        .tables
19928        .iter_mut()
19929        .find(|entry| entry.table_id == table_id)
19930        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
19931    replacement.name = new_name.to_string();
19932    replacement.state = TableState::Live;
19933
19934    for role in &mut catalog.roles {
19935        role.permissions.retain_mut(|permission| {
19936            retain_rebuilt_permission_columns(permission, new_name, &schema)
19937        });
19938    }
19939    for definition in &mut catalog.materialized_views {
19940        if let Some(incremental) = definition.incremental.as_mut() {
19941            if incremental.source_table == new_name
19942                && incremental.source_table_id == replaced_table_id
19943            {
19944                incremental.source_table_id = table_id;
19945            }
19946        }
19947    }
19948    advance_security_version(catalog)?;
19949    Ok(true)
19950}
19951
19952fn retain_rebuilt_permission_columns(
19953    permission: &mut crate::auth::Permission,
19954    target_table: &str,
19955    schema: &Schema,
19956) -> bool {
19957    use crate::auth::Permission;
19958    let columns = match permission {
19959        Permission::SelectColumns { table, columns }
19960        | Permission::InsertColumns { table, columns }
19961        | Permission::UpdateColumns { table, columns }
19962            if table == target_table =>
19963        {
19964            Some(columns)
19965        }
19966        _ => None,
19967    };
19968    if let Some(columns) = columns {
19969        columns.retain(|column| schema.column(column).is_some());
19970        !columns.is_empty()
19971    } else {
19972        true
19973    }
19974}
19975
19976fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
19977    use crate::auth::Permission;
19978    let table = match permission {
19979        Permission::Select { table }
19980        | Permission::Insert { table }
19981        | Permission::Update { table }
19982        | Permission::Delete { table }
19983        | Permission::SelectColumns { table, .. }
19984        | Permission::InsertColumns { table, .. }
19985        | Permission::UpdateColumns { table, .. } => Some(table),
19986        Permission::All | Permission::Ddl | Permission::Admin => None,
19987    };
19988    if let Some(table) = table.filter(|table| table.as_str() == old) {
19989        *table = new.to_string();
19990    }
19991}
19992
19993fn rename_permission_column(
19994    permission: &mut crate::auth::Permission,
19995    target_table: &str,
19996    old: &str,
19997    new: &str,
19998) {
19999    use crate::auth::Permission;
20000    let columns = match permission {
20001        Permission::SelectColumns { table, columns }
20002        | Permission::InsertColumns { table, columns }
20003        | Permission::UpdateColumns { table, columns }
20004            if table == target_table =>
20005        {
20006            Some(columns)
20007        }
20008        _ => None,
20009    };
20010    if let Some(column) = columns
20011        .into_iter()
20012        .flatten()
20013        .find(|column| column.as_str() == old)
20014    {
20015        *column = new.to_string();
20016    }
20017}
20018
20019pub(crate) fn validate_security_catalog(
20020    catalog: &Catalog,
20021    security: &crate::security::SecurityCatalog,
20022) -> Result<()> {
20023    let mut policy_names = HashSet::new();
20024    for table in &security.rls_tables {
20025        if catalog.live(table).is_none() {
20026            return Err(MongrelError::NotFound(format!(
20027                "RLS table {table:?} not found"
20028            )));
20029        }
20030    }
20031    for policy in &security.policies {
20032        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
20033            return Err(MongrelError::InvalidArgument(format!(
20034                "duplicate policy {:?} on {:?}",
20035                policy.name, policy.table
20036            )));
20037        }
20038        let schema = &catalog
20039            .live(&policy.table)
20040            .ok_or_else(|| {
20041                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
20042            })?
20043            .schema;
20044        if let Some(expression) = &policy.using {
20045            validate_security_expression(expression, schema)?;
20046        }
20047        if let Some(expression) = &policy.with_check {
20048            validate_security_expression(expression, schema)?;
20049        }
20050    }
20051    let mut mask_names = HashSet::new();
20052    for mask in &security.masks {
20053        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
20054            return Err(MongrelError::InvalidArgument(format!(
20055                "duplicate mask {:?} on {:?}",
20056                mask.name, mask.table
20057            )));
20058        }
20059        let column = catalog
20060            .live(&mask.table)
20061            .and_then(|entry| {
20062                entry
20063                    .schema
20064                    .columns
20065                    .iter()
20066                    .find(|column| column.id == mask.column)
20067            })
20068            .ok_or_else(|| {
20069                MongrelError::NotFound(format!(
20070                    "mask column {} on {:?} not found",
20071                    mask.column, mask.table
20072                ))
20073            })?;
20074        if matches!(
20075            mask.strategy,
20076            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
20077        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
20078        {
20079            return Err(MongrelError::InvalidArgument(format!(
20080                "mask {:?} requires a string/bytes column",
20081                mask.name
20082            )));
20083        }
20084    }
20085    Ok(())
20086}
20087
20088fn validate_security_expression(
20089    expression: &crate::security::SecurityExpr,
20090    schema: &Schema,
20091) -> Result<()> {
20092    use crate::security::SecurityExpr;
20093    match expression {
20094        SecurityExpr::True => Ok(()),
20095        SecurityExpr::ColumnEqCurrentUser { column }
20096        | SecurityExpr::ColumnEqValue { column, .. } => {
20097            if schema
20098                .columns
20099                .iter()
20100                .any(|candidate| candidate.id == *column)
20101            {
20102                Ok(())
20103            } else {
20104                Err(MongrelError::InvalidArgument(format!(
20105                    "security expression references unknown column id {column}"
20106                )))
20107            }
20108        }
20109        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
20110            validate_security_expression(left, schema)?;
20111            validate_security_expression(right, schema)
20112        }
20113        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
20114    }
20115}
20116
20117/// Remove canonical numeric table directories that no catalog generation owns.
20118fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
20119    let referenced = cat
20120        .tables
20121        .iter()
20122        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
20123        .map(|entry| entry.table_id)
20124        .collect::<HashSet<_>>();
20125    let tables_dir = root.join(TABLES_DIR);
20126    let entries = match std::fs::read_dir(&tables_dir) {
20127        Ok(entries) => entries,
20128        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
20129        Err(error) => return Err(error.into()),
20130    };
20131    for entry in entries {
20132        let entry = entry?;
20133        if !entry.file_type()?.is_dir() {
20134            continue;
20135        }
20136        let file_name = entry.file_name();
20137        let Some(name) = file_name.to_str() else {
20138            continue;
20139        };
20140        let Ok(table_id) = name.parse::<u64>() else {
20141            continue;
20142        };
20143        if name != table_id.to_string() {
20144            continue;
20145        }
20146        if !referenced.contains(&table_id) {
20147            crate::durable_file::remove_directory_all(&entry.path())?;
20148        }
20149    }
20150    Ok(())
20151}
20152
20153/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
20154/// #14). These dirs hold pending uniform-epoch runs from large transactions
20155/// that were aborted or crashed before commit. On open, all such dirs are safe
20156/// to remove because committed txns moved their runs to `_runs/` at publish.
20157fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
20158    for entry in &cat.tables {
20159        let txn_dir = root
20160            .join(TABLES_DIR)
20161            .join(entry.table_id.to_string())
20162            .join("_txn");
20163        if txn_dir.exists() {
20164            let _ = std::fs::remove_dir_all(&txn_dir);
20165        }
20166    }
20167}
20168
20169#[cfg(test)]
20170mod write_permission_tests {
20171    use super::*;
20172    use crate::txn::Staged;
20173
20174    struct NoopExternalBridge;
20175
20176    impl ExternalTriggerBridge for NoopExternalBridge {
20177        fn apply_trigger_external_write(
20178            &self,
20179            _entry: &ExternalTableEntry,
20180            base_state: Vec<u8>,
20181            _op: ExternalTriggerWrite,
20182        ) -> Result<ExternalTriggerWriteResult> {
20183            Ok(ExternalTriggerWriteResult::new(base_state))
20184        }
20185    }
20186
20187    fn assert_txn_namespace_full<T>(result: Result<T>) {
20188        assert!(matches!(result, Err(MongrelError::Full(_))));
20189    }
20190
20191    #[test]
20192    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
20193        let directory = tempfile::tempdir().unwrap();
20194        let database = Database::create(directory.path()).unwrap();
20195        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
20196        *database.next_txn_id.lock() = generation << 32;
20197        let before = crate::wal::SharedWal::replay(directory.path())
20198            .unwrap()
20199            .len();
20200        let bridge = NoopExternalBridge;
20201
20202        assert_txn_namespace_full(database.begin().commit());
20203        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
20204        assert_txn_namespace_full(
20205            database
20206                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
20207                .commit(),
20208        );
20209        assert_txn_namespace_full(
20210            database
20211                .begin_with_external_trigger_bridge(&bridge)
20212                .commit(),
20213        );
20214        assert_txn_namespace_full(
20215            database
20216                .begin_with_external_trigger_bridge_as(&bridge, None)
20217                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
20218        );
20219
20220        assert_eq!(
20221            crate::wal::SharedWal::replay(directory.path())
20222                .unwrap()
20223                .len(),
20224            before
20225        );
20226        drop(database);
20227        Database::open(directory.path()).unwrap();
20228    }
20229
20230    #[test]
20231    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
20232        let directory = tempfile::tempdir().unwrap();
20233        let table_dir = directory.path().join("7");
20234        crate::durable_file::create_directory_all(&table_dir).unwrap();
20235        let original_schema = test_schema();
20236        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
20237        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
20238        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
20239        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
20240        let original_bytes = std::fs::read(&schema_path).unwrap();
20241
20242        let mut replacement_schema = original_schema;
20243        replacement_schema.schema_id += 1;
20244        assert!(matches!(
20245            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
20246            Err(MongrelError::Conflict(_))
20247        ));
20248
20249        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
20250        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
20251        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
20252        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
20253    }
20254
20255    #[test]
20256    fn catalog_table_missing_storage_fails_without_recreating_it() {
20257        let directory = tempfile::tempdir().unwrap();
20258        let table_dir = {
20259            let database = Database::create(directory.path()).unwrap();
20260            database.create_table("docs", test_schema()).unwrap();
20261            directory
20262                .path()
20263                .join(TABLES_DIR)
20264                .join(database.table_id("docs").unwrap().to_string())
20265        };
20266        std::fs::remove_dir_all(&table_dir).unwrap();
20267
20268        assert!(matches!(
20269            Database::open(directory.path()),
20270            Err(MongrelError::NotFound(_))
20271        ));
20272        assert!(!table_dir.exists());
20273    }
20274
20275    #[test]
20276    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
20277        let directory = tempfile::tempdir().unwrap();
20278        let database = std::sync::Arc::new(
20279            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
20280        );
20281        database.create_user("alice", "old-password").unwrap();
20282        let old_identity = database.user_identity("alice").unwrap();
20283        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
20284        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
20285        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
20286        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
20287
20288        std::thread::scope(|scope| {
20289            let authenticate = {
20290                let database = std::sync::Arc::clone(&database);
20291                scope.spawn(move || {
20292                    database.authenticate_principal_inner("alice", "old-password", || {
20293                        verified_tx.send(()).unwrap();
20294                        resume_rx.recv().unwrap();
20295                    })
20296                })
20297            };
20298            verified_rx.recv().unwrap();
20299            let mutate = {
20300                let database = std::sync::Arc::clone(&database);
20301                scope.spawn(move || {
20302                    mutation_started_tx.send(()).unwrap();
20303                    database.drop_user("alice").unwrap();
20304                    database.create_user("alice", "new-password").unwrap();
20305                    mutation_done_tx.send(()).unwrap();
20306                })
20307            };
20308            mutation_started_rx.recv().unwrap();
20309            assert!(mutation_done_rx
20310                .recv_timeout(std::time::Duration::from_millis(50))
20311                .is_err());
20312            resume_tx.send(()).unwrap();
20313            let principal = authenticate.join().unwrap().unwrap().unwrap();
20314            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
20315            mutate.join().unwrap();
20316        });
20317
20318        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
20319        assert!(database
20320            .authenticate_principal("alice", "old-password")
20321            .unwrap()
20322            .is_none());
20323        assert!(database
20324            .authenticate_principal("alice", "new-password")
20325            .unwrap()
20326            .is_some());
20327    }
20328
20329    #[test]
20330    fn homogeneous_batch_summarizes_to_one_permission_decision() {
20331        let staging = (0..10_050)
20332            .map(|_| {
20333                (
20334                    7,
20335                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
20336                )
20337            })
20338            .collect::<Vec<_>>();
20339
20340        let needs = summarize_write_permissions(&staging);
20341        let table = needs.get(&7).unwrap();
20342        assert_eq!(needs.len(), 1);
20343        assert!(table.insert);
20344        assert_eq!(table.insert_columns, [1, 2]);
20345        assert!(!table.update);
20346        assert!(!table.delete);
20347        assert!(!table.truncate);
20348    }
20349
20350    #[test]
20351    fn mixed_writes_union_columns_and_preserve_empty_operations() {
20352        let staging = vec![
20353            (7, Staged::Put(vec![(2, Value::Int64(2))])),
20354            (7, Staged::Put(vec![(1, Value::Int64(1))])),
20355            (
20356                7,
20357                Staged::Update {
20358                    row_id: RowId(1),
20359                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
20360                    changed_columns: vec![2],
20361                },
20362            ),
20363            (7, Staged::Delete(RowId(2))),
20364            (8, Staged::Truncate),
20365        ];
20366
20367        let needs = summarize_write_permissions(&staging);
20368        let table = needs.get(&7).unwrap();
20369        assert_eq!(table.insert_columns, [1, 2]);
20370        assert!(table.update);
20371        assert_eq!(table.update_columns, [2]);
20372        assert!(table.delete);
20373        assert!(needs.get(&8).unwrap().truncate);
20374    }
20375
20376    #[test]
20377    fn final_permission_decisions_do_not_scale_with_rows() {
20378        let credentialless_dir = tempfile::tempdir().unwrap();
20379        let credentialless = Database::create(credentialless_dir.path()).unwrap();
20380        credentialless.create_table("docs", test_schema()).unwrap();
20381        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20382        credentialless
20383            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
20384            .unwrap();
20385        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
20386
20387        let authenticated_dir = tempfile::tempdir().unwrap();
20388        let authenticated =
20389            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
20390                .unwrap();
20391        authenticated.create_table("docs", test_schema()).unwrap();
20392        let admin = authenticated.resolve_principal("admin").unwrap();
20393        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20394        authenticated
20395            .validate_write_permissions(
20396                &puts(authenticated.table_id("docs").unwrap()),
20397                Some(&admin),
20398                None,
20399            )
20400            .unwrap();
20401        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20402    }
20403
20404    #[test]
20405    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
20406        let dir = tempfile::tempdir().unwrap();
20407        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20408        db.create_table("docs", test_schema()).unwrap();
20409        let admin = db.resolve_principal("admin").unwrap();
20410        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20411
20412        let mut transaction = db.begin_as(Some(admin));
20413        transaction
20414            .delete_batch("docs", (0..100).map(RowId).collect())
20415            .unwrap();
20416        transaction.commit().unwrap();
20417
20418        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
20419    }
20420
20421    #[test]
20422    fn truncate_validation_checks_admin_once_for_all_tables() {
20423        let dir = tempfile::tempdir().unwrap();
20424        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20425        db.create_table("first", test_schema()).unwrap();
20426        db.create_table("second", test_schema()).unwrap();
20427        let admin = db.resolve_principal("admin").unwrap();
20428        let staging = vec![
20429            (db.table_id("first").unwrap(), Staged::Truncate),
20430            (db.table_id("second").unwrap(), Staged::Truncate),
20431        ];
20432
20433        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20434        db.validate_write_permissions(&staging, Some(&admin), None)
20435            .unwrap();
20436        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20437    }
20438
20439    #[test]
20440    fn one_table_commit_batches_structural_work() {
20441        let dir = tempfile::tempdir().unwrap();
20442        let db = Database::create(dir.path()).unwrap();
20443        db.create_table("docs", test_schema()).unwrap();
20444        let table_id = db.table_id("docs").unwrap();
20445
20446        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
20447        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20448        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20449        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20450        db.transaction(|transaction| {
20451            for id in 0..100 {
20452                transaction.put("docs", vec![(1, Value::Int64(id))])?;
20453            }
20454            Ok(())
20455        })
20456        .unwrap();
20457
20458        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
20459        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20460        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20461        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20462
20463        let puts = crate::wal::SharedWal::replay(dir.path())
20464            .unwrap()
20465            .into_iter()
20466            .filter_map(|record| match record.op {
20467                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
20468                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
20469                        .unwrap()
20470                        .len(),
20471                ),
20472                _ => None,
20473            })
20474            .collect::<Vec<_>>();
20475        assert_eq!(puts, [100]);
20476
20477        let row_ids = db
20478            .table("docs")
20479            .unwrap()
20480            .lock()
20481            .visible_rows(db.snapshot().0)
20482            .unwrap()
20483            .into_iter()
20484            .take(2)
20485            .map(|row| row.row_id)
20486            .collect::<Vec<_>>();
20487        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20488        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20489        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20490        db.transaction(|transaction| {
20491            for row_id in row_ids {
20492                transaction.delete("docs", row_id)?;
20493            }
20494            Ok(())
20495        })
20496        .unwrap();
20497        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20498        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20499        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20500
20501        let deletes = crate::wal::SharedWal::replay(dir.path())
20502            .unwrap()
20503            .into_iter()
20504            .filter_map(|record| match record.op {
20505                crate::wal::Op::Delete {
20506                    table_id: id,
20507                    row_ids,
20508                } if id == table_id => Some(row_ids.len()),
20509                _ => None,
20510            })
20511            .collect::<Vec<_>>();
20512        assert_eq!(deletes, [2]);
20513    }
20514
20515    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
20516        (0..10_050)
20517            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
20518            .collect()
20519    }
20520
20521    fn test_schema() -> Schema {
20522        Schema {
20523            columns: vec![ColumnDef {
20524                id: 1,
20525                name: "id".into(),
20526                ty: TypeId::Int64,
20527                flags: crate::schema::ColumnFlags::empty()
20528                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20529                default_value: None,
20530                embedding_source: None,
20531            }],
20532            ..Schema::default()
20533        }
20534    }
20535}
20536
20537#[cfg(test)]
20538mod cdc_bounds_tests {
20539    use super::*;
20540
20541    #[test]
20542    fn retained_byte_limit_rejects_without_allocating_payload() {
20543        let mut retained = 0;
20544        let error = charge_cdc_bytes(
20545            &mut retained,
20546            CDC_MAX_RETAINED_BYTES.saturating_add(1),
20547            "CDC retained bytes",
20548        )
20549        .unwrap_err();
20550        assert!(matches!(
20551            error,
20552            MongrelError::ResourceLimitExceeded {
20553                resource: "CDC retained bytes",
20554                ..
20555            }
20556        ));
20557    }
20558
20559    #[test]
20560    fn row_json_estimate_accounts_for_byte_array_expansion() {
20561        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
20562            .with_column(1, Value::Bytes(vec![0; 1024]));
20563        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
20564    }
20565}
20566
20567#[cfg(test)]
20568mod generation_metrics_tests {
20569    use super::*;
20570    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20571
20572    #[test]
20573    fn legacy_cow_fallback_is_measured() {
20574        let dir = tempfile::tempdir().unwrap();
20575        let table = Table::create(
20576            dir.path(),
20577            Schema {
20578                columns: vec![ColumnDef {
20579                    id: 1,
20580                    name: "id".into(),
20581                    ty: TypeId::Int64,
20582                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20583                    default_value: None,
20584                    embedding_source: None,
20585                }],
20586                ..Schema::default()
20587            },
20588            1,
20589        )
20590        .unwrap();
20591        let handle = TableHandle::from_table(table);
20592        let held = match &handle.inner {
20593            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
20594            TableHandleInner::Direct(_) => unreachable!(),
20595        };
20596
20597        handle.lock().set_sync_byte_threshold(1);
20598
20599        let stats = handle.generation_stats();
20600        assert_eq!(stats.cow_clone_count, 1);
20601        assert!(stats.estimated_cow_clone_bytes > 0);
20602        drop(held);
20603    }
20604}
20605
20606#[cfg(test)]
20607mod trigger_engine_tests {
20608    use super::*;
20609
20610    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
20611        WriteEvent {
20612            table: "test".into(),
20613            kind: TriggerEvent::Insert,
20614            new: Some(TriggerRowImage {
20615                columns: new_cells.iter().cloned().collect(),
20616            }),
20617            old: Some(TriggerRowImage {
20618                columns: old_cells.iter().cloned().collect(),
20619            }),
20620            changed_columns: Vec::new(),
20621            op_indices: Vec::new(),
20622            put_idx: None,
20623            trigger_stack: Vec::new(),
20624        }
20625    }
20626
20627    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
20628        WriteEvent {
20629            table: "test".into(),
20630            kind: TriggerEvent::Insert,
20631            new: Some(TriggerRowImage {
20632                columns: new_cells.iter().cloned().collect(),
20633            }),
20634            old: None,
20635            changed_columns: Vec::new(),
20636            op_indices: Vec::new(),
20637            put_idx: None,
20638            trigger_stack: Vec::new(),
20639        }
20640    }
20641
20642    #[test]
20643    fn value_order_int64_vs_float64() {
20644        assert_eq!(
20645            value_order(&Value::Int64(5), &Value::Float64(5.0)),
20646            Some(std::cmp::Ordering::Equal)
20647        );
20648        assert_eq!(
20649            value_order(&Value::Int64(5), &Value::Float64(3.0)),
20650            Some(std::cmp::Ordering::Greater)
20651        );
20652        assert_eq!(
20653            value_order(&Value::Int64(2), &Value::Float64(3.0)),
20654            Some(std::cmp::Ordering::Less)
20655        );
20656    }
20657
20658    #[test]
20659    fn value_order_null_returns_none() {
20660        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
20661        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
20662        assert_eq!(value_order(&Value::Null, &Value::Null), None);
20663    }
20664
20665    #[test]
20666    fn value_order_cross_group_returns_none() {
20667        assert_eq!(
20668            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
20669            None
20670        );
20671        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
20672        assert_eq!(
20673            value_order(
20674                &Value::Embedding(vec![1.0, 2.0]),
20675                &Value::Embedding(vec![1.0, 2.0])
20676            ),
20677            None
20678        );
20679    }
20680
20681    #[test]
20682    fn eval_trigger_expr_ranges_and_booleans() {
20683        let expr = TriggerExpr::And {
20684            left: Box::new(TriggerExpr::Gt {
20685                left: TriggerValue::NewColumn(1),
20686                right: TriggerValue::Literal(Value::Int64(0)),
20687            }),
20688            right: Box::new(TriggerExpr::Lte {
20689                left: TriggerValue::NewColumn(1),
20690                right: TriggerValue::Literal(Value::Int64(100)),
20691            }),
20692        };
20693        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
20694        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
20695        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
20696
20697        let or_expr = TriggerExpr::Or {
20698            left: Box::new(TriggerExpr::Lt {
20699                left: TriggerValue::NewColumn(1),
20700                right: TriggerValue::Literal(Value::Int64(0)),
20701            }),
20702            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
20703                TriggerValue::OldColumn(2),
20704            )))),
20705        };
20706        assert!(eval_trigger_expr(
20707            &or_expr,
20708            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
20709        )
20710        .unwrap());
20711        assert!(!eval_trigger_expr(
20712            &or_expr,
20713            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
20714        )
20715        .unwrap());
20716
20717        assert!(eval_trigger_expr(
20718            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
20719            &event_insert(&[])
20720        )
20721        .unwrap());
20722        assert!(!eval_trigger_expr(
20723            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
20724            &event_insert(&[])
20725        )
20726        .unwrap());
20727        assert!(!eval_trigger_expr(
20728            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
20729            &event_insert(&[])
20730        )
20731        .unwrap());
20732    }
20733}
20734
20735#[cfg(test)]
20736mod core_resource_tests {
20737    use super::*;
20738
20739    fn int_pk_schema() -> Schema {
20740        Schema {
20741            columns: vec![ColumnDef {
20742                id: 1,
20743                name: "id".into(),
20744                ty: TypeId::Int64,
20745                flags: crate::schema::ColumnFlags::empty()
20746                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20747                default_value: None,
20748                embedding_source: None,
20749            }],
20750            ..Schema::default()
20751        }
20752    }
20753
20754    #[test]
20755    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
20756        let dir = tempfile::tempdir().unwrap();
20757        let db = Database::create(dir.path()).unwrap();
20758        assert_eq!(
20759            db.memory_governor().max_bytes(),
20760            DEFAULT_MEMORY_BUDGET_BYTES
20761        );
20762        assert_eq!(
20763            db.spill_manager().config().global_bytes,
20764            DEFAULT_TEMP_DISK_BUDGET_BYTES
20765        );
20766        assert!(db.job_registry().list().is_empty());
20767        // S1E-002: class defaults seeded at open; no external embedding vendor.
20768        assert_eq!(
20769            db.resource_groups().len(),
20770            crate::resource::WorkloadClass::ALL.len()
20771        );
20772        assert!(db.resource_groups().get("control").is_some());
20773        assert!(db.embedding_providers().list_ids().is_empty());
20774        // Application-supplied path refuses generation (no silent hashed vectors).
20775        let err = db
20776            .embedding_providers()
20777            .embed(
20778                &crate::embedding::EmbeddingSource::SuppliedByApplication,
20779                &["text"],
20780                4,
20781            )
20782            .unwrap_err();
20783        assert!(matches!(
20784            err,
20785            crate::embedding::EmbeddingError::SuppliedByApplication
20786        ));
20787    }
20788
20789    #[test]
20790    fn lock_rows_for_update_acquires_exclusive_row_locks() {
20791        use crate::locks::LockKey;
20792        use crate::rowid::RowId;
20793
20794        let dir = tempfile::tempdir().unwrap();
20795        let db = Database::create(dir.path()).unwrap();
20796        let txn_id = db.allocate_lock_txn_id().unwrap();
20797        let rid = RowId(42);
20798        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
20799        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20800        db.release_txn_locks(txn_id);
20801        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20802    }
20803
20804    #[test]
20805    fn open_with_options_sizes_the_core_budgets() {
20806        let dir = tempfile::tempdir().unwrap();
20807        let db = Database::create(dir.path()).unwrap();
20808        drop(db);
20809        let db = Database::open_with_options(
20810            dir.path(),
20811            OpenOptions::default()
20812                .with_memory_budget_bytes(256 * 1024 * 1024)
20813                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
20814        )
20815        .unwrap();
20816        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
20817        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
20818    }
20819
20820    #[test]
20821    fn zero_budgets_are_rejected() {
20822        let dir = tempfile::tempdir().unwrap();
20823        let db = Database::create(dir.path()).unwrap();
20824        drop(db);
20825        let result = Database::open_with_options(
20826            dir.path(),
20827            OpenOptions::default().with_memory_budget_bytes(0),
20828        );
20829        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20830        let result = Database::open_with_options(
20831            dir.path(),
20832            OpenOptions::default().with_temp_disk_budget_bytes(0),
20833        );
20834        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20835    }
20836
20837    #[test]
20838    fn page_caches_reserve_under_the_governor() {
20839        let dir = tempfile::tempdir().unwrap();
20840        let db = Database::create(dir.path()).unwrap();
20841        db.create_table("t", int_pk_schema()).unwrap();
20842        let mut txn = db.begin();
20843        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20844        txn.commit().unwrap();
20845        // S1E-003: both caches hold reservations under the core's governor, so
20846        // the governor's per-class accounting mirrors their live bytes, and
20847        // both are registered as reclaimable subsystems it can evict.
20848        let stats = db.memory_governor().stats();
20849        assert_eq!(
20850            stats.usage_for(crate::memory::MemoryClass::PageCache),
20851            db.page_cache.used_bytes()
20852        );
20853        assert_eq!(
20854            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
20855            db.decoded_cache.used_bytes()
20856        );
20857        assert_eq!(
20858            db.memory_governor().reclaimable_bytes(),
20859            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
20860        );
20861        // Driving an eviction through the governor is safe at any level.
20862        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
20863    }
20864
20865    #[test]
20866    fn job_registry_persists_across_reopen() {
20867        let dir = tempfile::tempdir().unwrap();
20868        let db = Database::create(dir.path()).unwrap();
20869        db.create_table("t", int_pk_schema()).unwrap();
20870        let job_id = db
20871            .job_registry()
20872            .submit(
20873                crate::jobs::JobKind::IndexBuild,
20874                crate::jobs::JobTarget {
20875                    table: "t".to_string(),
20876                    index: Some("idx".to_string()),
20877                },
20878            )
20879            .unwrap();
20880        drop(db);
20881        let db = Database::open(dir.path()).unwrap();
20882        let record = db.job_registry().get(job_id).expect("job survives reopen");
20883        assert_eq!(record.state, crate::jobs::JobState::Pending);
20884    }
20885
20886    #[test]
20887    fn spill_manager_open_sweeps_stale_temp_tree() {
20888        let dir = tempfile::tempdir().unwrap();
20889        let db = Database::create(dir.path()).unwrap();
20890        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
20891        std::fs::create_dir_all(&stale).unwrap();
20892        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
20893        drop(db);
20894        let db = Database::open(dir.path()).unwrap();
20895        assert!(
20896            !stale.exists(),
20897            "the startup sweep removes stale spill files (S1E-004)"
20898        );
20899        // A fresh session can start against the swept manager.
20900        let session = db
20901            .spill_manager()
20902            .begin_query(
20903                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
20904                1024 * 1024,
20905            )
20906            .unwrap();
20907        assert_eq!(session.used(), 0);
20908    }
20909}
20910
20911#[cfg(test)]
20912mod version_pin_tests {
20913    use super::*;
20914
20915    fn int_pk_schema() -> Schema {
20916        Schema {
20917            columns: vec![ColumnDef {
20918                id: 1,
20919                name: "id".into(),
20920                ty: TypeId::Int64,
20921                flags: crate::schema::ColumnFlags::empty()
20922                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20923                default_value: None,
20924                embedding_source: None,
20925            }],
20926            ..Schema::default()
20927        }
20928    }
20929
20930    fn pins_for(
20931        report: &[TablePinsReport],
20932        table: &str,
20933        source: crate::retention::PinSource,
20934    ) -> Option<crate::retention::PinInfo> {
20935        report
20936            .iter()
20937            .find(|entry| entry.table == table)
20938            .and_then(|entry| entry.pins.get(source).cloned())
20939    }
20940
20941    #[test]
20942    fn backup_boundary_registers_backup_pitr_pin() {
20943        let source = tempfile::tempdir().unwrap();
20944        let destination_parent = tempfile::tempdir().unwrap();
20945        let destination = destination_parent.path().join("backup");
20946        let db = Arc::new(Database::create(source.path()).unwrap());
20947        db.create_table("t", int_pk_schema()).unwrap();
20948        let mut txn = db.begin();
20949        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
20950        let boundary_epoch = txn.commit().unwrap();
20951
20952        let hold = Arc::new(std::sync::Barrier::new(2));
20953        let resume = Arc::new(std::sync::Barrier::new(2));
20954        db.__set_backup_hook({
20955            let hold = Arc::clone(&hold);
20956            let resume = Arc::clone(&resume);
20957            move || {
20958                hold.wait();
20959                resume.wait();
20960            }
20961        });
20962
20963        let backup = {
20964            let db = Arc::clone(&db);
20965            let destination = destination.clone();
20966            std::thread::spawn(move || db.hot_backup(destination))
20967        };
20968        hold.wait();
20969        // The hook fires while the backup's pins are held: the boundary must
20970        // show up as a BackupPitr pin on the table's unified registry.
20971        let report = db.version_pins_report();
20972        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
20973            .expect("backup boundary must register a BackupPitr pin");
20974        assert_eq!(pin.oldest_epoch, boundary_epoch);
20975        assert!(pin.pin_count >= 1);
20976        resume.wait();
20977        backup.join().unwrap().unwrap();
20978
20979        let report = db.version_pins_report();
20980        assert!(
20981            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
20982            "the BackupPitr pin releases when the backup finishes"
20983        );
20984    }
20985
20986    #[test]
20987    fn snapshot_and_read_generation_pins_surface_in_report() {
20988        let dir = tempfile::tempdir().unwrap();
20989        let db = Database::create(dir.path()).unwrap();
20990        db.create_table("t", int_pk_schema()).unwrap();
20991        let mut txn = db.begin();
20992        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20993        txn.commit().unwrap();
20994
20995        let (_snapshot, guard) = db.snapshot();
20996        let report = db.version_pins_report();
20997        assert!(
20998            pins_for(
20999                &report,
21000                "t",
21001                crate::retention::PinSource::TransactionSnapshot
21002            )
21003            .is_some(),
21004            "a database snapshot projects the TransactionSnapshot source"
21005        );
21006        drop(guard);
21007
21008        let handle = db.table("t").unwrap();
21009        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
21010        let report = db.version_pins_report();
21011        assert!(
21012            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
21013            "a cloned read generation registers a ReadGeneration pin"
21014        );
21015        drop(generation);
21016
21017        let report = db.version_pins_report();
21018        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
21019        assert!(
21020            entry
21021                .pins
21022                .get(crate::retention::PinSource::BackupPitr)
21023                .is_none()
21024                && entry
21025                    .pins
21026                    .get(crate::retention::PinSource::Replication)
21027                    .is_none()
21028                && entry
21029                    .pins
21030                    .get(crate::retention::PinSource::OnlineIndexBuild)
21031                    .is_none(),
21032            "untaken sources stay absent from the report"
21033        );
21034    }
21035}
21036
21037#[cfg(test)]
21038mod lock_manager_tests {
21039    use super::*;
21040    use crate::locks::LockKey;
21041
21042    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
21043        ColumnDef {
21044            id,
21045            name: name.into(),
21046            ty,
21047            flags,
21048            default_value: None,
21049            embedding_source: None,
21050        }
21051    }
21052
21053    fn unique_schema() -> Schema {
21054        let mut constraints = crate::constraint::TableConstraints::default();
21055        constraints
21056            .uniques
21057            .push(crate::constraint::UniqueConstraint {
21058                id: 1,
21059                name: "users_email_unique".into(),
21060                columns: vec![1],
21061            });
21062        Schema {
21063            columns: vec![
21064                col(
21065                    0,
21066                    "id",
21067                    TypeId::Int64,
21068                    crate::schema::ColumnFlags::empty()
21069                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21070                ),
21071                col(
21072                    1,
21073                    "email",
21074                    TypeId::Bytes,
21075                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
21076                ),
21077            ],
21078            constraints,
21079            ..Schema::default()
21080        }
21081    }
21082
21083    fn parent_schema() -> Schema {
21084        Schema {
21085            columns: vec![col(
21086                0,
21087                "id",
21088                TypeId::Int64,
21089                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
21090            )],
21091            ..Schema::default()
21092        }
21093    }
21094
21095    fn child_schema() -> Schema {
21096        let mut constraints = crate::constraint::TableConstraints::default();
21097        constraints
21098            .foreign_keys
21099            .push(crate::constraint::ForeignKey {
21100                id: 1,
21101                name: "child_parent_fk".into(),
21102                columns: vec![1],
21103                ref_table: "parent".into(),
21104                ref_columns: vec![0],
21105                on_delete: crate::constraint::FkAction::Restrict,
21106                on_update: crate::constraint::FkAction::Restrict,
21107            });
21108        Schema {
21109            columns: vec![
21110                col(
21111                    0,
21112                    "id",
21113                    TypeId::Int64,
21114                    crate::schema::ColumnFlags::empty()
21115                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21116                ),
21117                col(
21118                    1,
21119                    "pid",
21120                    TypeId::Int64,
21121                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
21122                ),
21123            ],
21124            constraints,
21125            ..Schema::default()
21126        }
21127    }
21128
21129    fn auto_inc_schema() -> Schema {
21130        Schema {
21131            columns: vec![col(
21132                0,
21133                "id",
21134                TypeId::Int64,
21135                crate::schema::ColumnFlags::empty()
21136                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
21137                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
21138            )],
21139            ..Schema::default()
21140        }
21141    }
21142
21143    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
21144        let mut key = b"pk:".to_vec();
21145        key.extend_from_slice(&Value::Int64(value).encode_key());
21146        LockKey::key(table_id, key)
21147    }
21148
21149    #[test]
21150    fn unique_claims_serialize_concurrent_commits() {
21151        let dir = tempfile::tempdir().unwrap();
21152        let db = Arc::new(Database::create(dir.path()).unwrap());
21153        let table_id = db.create_table("users", unique_schema()).unwrap();
21154        let pk_key = pk_lock_key(table_id, 1);
21155        let entered = Arc::new(std::sync::Barrier::new(2));
21156        let resume = Arc::new(std::sync::Barrier::new(2));
21157        let parked = Arc::new(AtomicBool::new(false));
21158        db.__set_catalog_commit_hook({
21159            let entered = Arc::clone(&entered);
21160            let resume = Arc::clone(&resume);
21161            let parked = Arc::clone(&parked);
21162            move || {
21163                // Park only the first commit to reach the sequencer; later
21164                // commits pass straight through.
21165                if !parked.swap(true, Ordering::SeqCst) {
21166                    entered.wait();
21167                    resume.wait();
21168                }
21169            }
21170        });
21171
21172        let mut txn_a = db.begin();
21173        txn_a
21174            .put(
21175                "users",
21176                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21177            )
21178            .unwrap();
21179        let a_id = txn_a.txn_id();
21180        let (a_tx, a_rx) = std::sync::mpsc::channel();
21181        let (b_tx, b_rx) = std::sync::mpsc::channel();
21182        std::thread::scope(|scope| {
21183            scope.spawn(|| {
21184                a_tx.send(txn_a.commit()).unwrap();
21185            });
21186            entered.wait();
21187            // A is parked in the sequencer holding its unique claims.
21188            assert!(
21189                db.lock_manager().holds(a_id, &pk_key),
21190                "primary-key claim must be held until the commit ends"
21191            );
21192            let mut uq_key = format!("uq{}:", 1).into_bytes();
21193            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
21194                .into_iter()
21195                .collect();
21196            uq_key.extend_from_slice(
21197                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
21198            );
21199            assert!(
21200                db.lock_manager()
21201                    .holds(a_id, &LockKey::key(table_id, uq_key)),
21202                "declared-unique claim must be held until the commit ends"
21203            );
21204
21205            let mut txn_b = db.begin();
21206            txn_b
21207                .put(
21208                    "users",
21209                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
21210                )
21211                .unwrap();
21212            scope.spawn(|| {
21213                b_tx.send(txn_b.commit()).unwrap();
21214            });
21215            std::thread::sleep(std::time::Duration::from_millis(100));
21216            assert!(
21217                b_rx.try_recv().is_err(),
21218                "the concurrent claim must block until A ends its transaction"
21219            );
21220            resume.wait();
21221            assert!(a_rx.recv().unwrap().is_ok());
21222            let b_result = b_rx.recv().unwrap();
21223            assert!(
21224                matches!(b_result, Err(MongrelError::Conflict(_))),
21225                "the loser surfaces a conflict after serializing: {b_result:?}"
21226            );
21227        });
21228        assert!(
21229            !db.lock_manager().holds(a_id, &pk_key),
21230            "no phantom holds remain after the commit"
21231        );
21232    }
21233
21234    #[test]
21235    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
21236        let dir = tempfile::tempdir().unwrap();
21237        let db = Arc::new(Database::create(dir.path()).unwrap());
21238        db.create_table("parent", parent_schema()).unwrap();
21239        db.create_table("child", child_schema()).unwrap();
21240        let mut seed = db.begin();
21241        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21242        seed.commit().unwrap();
21243
21244        let entered = Arc::new(std::sync::Barrier::new(2));
21245        let resume = Arc::new(std::sync::Barrier::new(2));
21246        db.__set_fk_lock_hook({
21247            let entered = Arc::clone(&entered);
21248            let resume = Arc::clone(&resume);
21249            move || {
21250                entered.wait();
21251                resume.wait();
21252            }
21253        });
21254
21255        let mut txn_a = db.begin();
21256        txn_a
21257            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
21258            .unwrap();
21259        let a_id = txn_a.txn_id();
21260        let (a_tx, a_rx) = std::sync::mpsc::channel();
21261        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
21262        std::thread::scope(|scope| {
21263            scope.spawn(|| {
21264                a_tx.send(txn_a.commit()).unwrap();
21265            });
21266            entered.wait();
21267            // A is parked mid-validation: schema barrier held Shared.
21268            assert!(
21269                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
21270                "DML holds the schema barrier Shared for its commit"
21271            );
21272            let db = Arc::clone(&db);
21273            scope.spawn(move || {
21274                ddl_tx.send(db.drop_table("parent")).unwrap();
21275            });
21276            std::thread::sleep(std::time::Duration::from_millis(100));
21277            assert!(
21278                ddl_rx.try_recv().is_err(),
21279                "DDL must wait on the Exclusive schema barrier while DML is in flight"
21280            );
21281            resume.wait();
21282            // A now finishes and the waiting DDL proceeds. A's commit may
21283            // legitimately lose the publish race against the DDL's security
21284            // version advance — the designed "security policy changed during
21285            // write" outcome — or win it; the barrier guarantees only that the
21286            // DDL could not proceed before A released it.
21287            let a_result = a_rx.recv().unwrap();
21288            match &a_result {
21289                Ok(_) => {}
21290                Err(MongrelError::Conflict(message)) => {
21291                    assert!(
21292                        message.contains("security policy changed during write"),
21293                        "unexpected commit conflict: {message}"
21294                    );
21295                }
21296                other => panic!("unexpected commit outcome: {other:?}"),
21297            }
21298            assert!(ddl_rx.recv().unwrap().is_ok());
21299        });
21300        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
21301    }
21302
21303    #[test]
21304    fn auto_increment_sequence_barrier_held_until_commit() {
21305        let dir = tempfile::tempdir().unwrap();
21306        let db = Database::create(dir.path()).unwrap();
21307        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21308        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21309        let entered = Arc::new(std::sync::Barrier::new(2));
21310        let resume = Arc::new(std::sync::Barrier::new(2));
21311        let parked = Arc::new(AtomicBool::new(false));
21312        db.__set_catalog_commit_hook({
21313            let entered = Arc::clone(&entered);
21314            let resume = Arc::clone(&resume);
21315            let parked = Arc::clone(&parked);
21316            move || {
21317                if !parked.swap(true, Ordering::SeqCst) {
21318                    entered.wait();
21319                    resume.wait();
21320                }
21321            }
21322        });
21323
21324        let mut txn_a = db.begin();
21325        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
21326        let a_id = txn_a.txn_id();
21327        // The stage-time allocation already holds the barrier.
21328        assert!(
21329            db.lock_manager().holds(a_id, &barrier_key),
21330            "sequence allocation takes the barrier at stage time"
21331        );
21332        let (a_tx, a_rx) = std::sync::mpsc::channel();
21333        std::thread::scope(|scope| {
21334            scope.spawn(|| {
21335                a_tx.send(txn_a.commit()).unwrap();
21336            });
21337            entered.wait();
21338            assert!(
21339                db.lock_manager().holds(a_id, &barrier_key),
21340                "the barrier is held through the commit"
21341            );
21342            resume.wait();
21343            assert!(a_rx.recv().unwrap().is_ok());
21344        });
21345        assert!(
21346            !db.lock_manager().holds(a_id, &barrier_key),
21347            "the barrier releases when the commit ends"
21348        );
21349    }
21350
21351    #[test]
21352    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
21353        let dir = tempfile::tempdir().unwrap();
21354        let db = Database::create(dir.path()).unwrap();
21355        db.create_table("parent", parent_schema()).unwrap();
21356        db.create_table("child", child_schema()).unwrap();
21357        let mut seed = db.begin();
21358        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21359        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
21360        seed.commit().unwrap();
21361        let (rid1, rid2) = {
21362            let handle = db.table("parent").unwrap();
21363            let table = handle.lock();
21364            let rid = |pk: i64| {
21365                table
21366                    .lookup_pk(&Value::Int64(pk).encode_key())
21367                    .expect("seeded parent row")
21368            };
21369            (rid(1), rid(2))
21370        };
21371
21372        // The hook fires after every successful FK lock acquisition; park the
21373        // first two (A's and B's Exclusive delete-side claims) so both are
21374        // held before either transaction attempts its Shared insert-side
21375        // claim — a deterministic wait-for cycle.
21376        let rendezvous = Arc::new(std::sync::Barrier::new(2));
21377        let calls = Arc::new(AtomicUsize::new(0));
21378        db.__set_fk_lock_hook({
21379            let rendezvous = Arc::clone(&rendezvous);
21380            let calls = Arc::clone(&calls);
21381            move || {
21382                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
21383                    rendezvous.wait();
21384                }
21385            }
21386        });
21387
21388        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
21389        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
21390        let mut txn_a = db.begin();
21391        txn_a.delete("parent", rid1).unwrap();
21392        txn_a
21393            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
21394            .unwrap();
21395        let mut txn_b = db.begin();
21396        txn_b.delete("parent", rid2).unwrap();
21397        txn_b
21398            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
21399            .unwrap();
21400        let b_id = txn_b.txn_id();
21401
21402        let (a_tx, a_rx) = std::sync::mpsc::channel();
21403        let (b_tx, b_rx) = std::sync::mpsc::channel();
21404        std::thread::scope(|scope| {
21405            scope.spawn(|| {
21406                a_tx.send(txn_a.commit()).unwrap();
21407            });
21408            scope.spawn(|| {
21409                b_tx.send(txn_b.commit()).unwrap();
21410            });
21411            let a_result = a_rx.recv().unwrap();
21412            let b_result = b_rx.recv().unwrap();
21413            assert!(
21414                a_result.is_ok(),
21415                "the survivor commits once the victim releases: {a_result:?}"
21416            );
21417            match b_result {
21418                Err(MongrelError::Deadlock { victim, .. }) => {
21419                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
21420                }
21421                other => panic!("the victim must surface a deadlock, got {other:?}"),
21422            }
21423        });
21424        // No phantom holds survive the victim's release.
21425        let fk_key = |table: &str, pk: i64| {
21426            let table_id = db.table_id(table).unwrap();
21427            let mut key = b"fk:".to_vec();
21428            key.extend_from_slice(&Value::Int64(pk).encode_key());
21429            LockKey::key(table_id, key)
21430        };
21431        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
21432        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
21433    }
21434
21435    #[test]
21436    fn locks_release_after_commit_rollback_and_failed_commit() {
21437        let dir = tempfile::tempdir().unwrap();
21438        let db = Database::create(dir.path()).unwrap();
21439        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21440        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21441
21442        // Successful commit: the stage-time barrier releases with the commit.
21443        let mut txn = db.begin();
21444        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21445        let committed_id = txn.txn_id();
21446        txn.commit().unwrap();
21447        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
21448
21449        // Rollback: the stage-time barrier releases with the drop.
21450        let mut txn = db.begin();
21451        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21452        let rolled_back_id = txn.txn_id();
21453        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
21454        txn.rollback();
21455        assert!(
21456            !db.lock_manager().holds(rolled_back_id, &barrier_key),
21457            "rollback must not leave phantom holds"
21458        );
21459
21460        // Failed commit (declared-unique violation): the claims release with
21461        // the error.
21462        db.create_table("users", unique_schema()).unwrap();
21463        let users_id = db.table_id("users").unwrap();
21464        let mut seed = db.begin();
21465        seed.put(
21466            "users",
21467            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21468        )
21469        .unwrap();
21470        seed.commit().unwrap();
21471        let mut txn = db.begin();
21472        // A different primary key but the same declared-unique email: the
21473        // Phase B unique check rejects this commit.
21474        txn.put(
21475            "users",
21476            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
21477        )
21478        .unwrap();
21479        let failed_id = txn.txn_id();
21480        let result = txn.commit();
21481        assert!(matches!(result, Err(MongrelError::Conflict(_))));
21482        assert!(
21483            !db.lock_manager()
21484                .holds(failed_id, &pk_lock_key(users_id, 2)),
21485            "a failed commit must not leave phantom holds"
21486        );
21487    }
21488}
21489
21490#[cfg(test)]
21491mod lifecycle_tests {
21492    use super::*;
21493
21494    fn int_pk_schema() -> Schema {
21495        Schema {
21496            columns: vec![ColumnDef {
21497                id: 1,
21498                name: "id".into(),
21499                ty: TypeId::Int64,
21500                flags: crate::schema::ColumnFlags::empty()
21501                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21502                default_value: None,
21503                embedding_source: None,
21504            }],
21505            ..Schema::default()
21506        }
21507    }
21508
21509    #[test]
21510    fn poisoned_core_rejects_operations_with_typed_errors() {
21511        let dir = tempfile::tempdir().unwrap();
21512        let db = Database::create(dir.path()).unwrap();
21513        db.create_table("t", int_pk_schema()).unwrap();
21514        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
21515
21516        // Drive the exact two-state poison the fsync-error sites set
21517        // (write-path flag + lifecycle transition), without process-global
21518        // fault injection, which would leak into parallel tests. The fsync
21519        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
21520        db.poisoned.store(true, Ordering::Relaxed);
21521        db.lifecycle.poison();
21522        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
21523
21524        // Guarded operations without their own write-path poison check reject
21525        // at admission with the lifecycle Conflict...
21526        let error = db.gc().unwrap_err();
21527        assert!(
21528            matches!(error, MongrelError::Conflict(_)),
21529            "gc must reject on a poisoned core: {error:?}"
21530        );
21531        let error = db.compact().unwrap_err();
21532        assert!(
21533            matches!(error, MongrelError::Conflict(_)),
21534            "compact must reject on a poisoned core: {error:?}"
21535        );
21536        assert!(db.operation_guard().is_err());
21537        // ...while paths that already checked the write-path flag keep their
21538        // legacy error.
21539        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
21540        assert!(
21541            error.to_string().contains("database poisoned"),
21542            "the legacy poison error still wins where it existed: {error:?}"
21543        );
21544        let mut txn = db.begin();
21545        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
21546        assert!(txn
21547            .commit()
21548            .unwrap_err()
21549            .to_string()
21550            .contains("database poisoned"));
21551    }
21552
21553    #[test]
21554    fn shutdown_waits_for_operation_guards_to_drain() {
21555        let dir = tempfile::tempdir().unwrap();
21556        let db = Arc::new(Database::create(dir.path()).unwrap());
21557        db.create_table("t", int_pk_schema()).unwrap();
21558        // The guard holds the lifecycle's Arc, not the database's, so the
21559        // exclusive-owner shutdown can proceed to its drain step below.
21560        let guard = db.operation_guard().unwrap();
21561        let (started_tx, started_rx) = std::sync::mpsc::channel();
21562        let (done_tx, done_rx) = std::sync::mpsc::channel();
21563        let shutdown_thread = std::thread::spawn(move || {
21564            started_tx.send(()).unwrap();
21565            let result = db.shutdown();
21566            let _ = done_tx.send(result);
21567        });
21568        started_rx.recv().unwrap();
21569        std::thread::sleep(std::time::Duration::from_millis(100));
21570        assert!(
21571            done_rx.try_recv().is_err(),
21572            "shutdown must wait for the outstanding guard to drain"
21573        );
21574        drop(guard);
21575        shutdown_thread.join().unwrap();
21576        assert!(
21577            done_rx.recv().unwrap().is_ok(),
21578            "shutdown completes once the guard drops"
21579        );
21580    }
21581}
21582
21583#[cfg(test)]
21584mod commit_ts_ledger_tests {
21585    use super::*;
21586    use crate::memtable::Row;
21587
21588    fn int_pk_schema() -> Schema {
21589        Schema {
21590            columns: vec![ColumnDef {
21591                id: 1,
21592                name: "id".into(),
21593                ty: TypeId::Int64,
21594                flags: crate::schema::ColumnFlags::empty()
21595                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21596                default_value: None,
21597                embedding_source: None,
21598            }],
21599            ..Schema::default()
21600        }
21601    }
21602
21603    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
21604        let mut txn = db.begin();
21605        let handle = txn.state_handle();
21606        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
21607        let epoch = txn.commit().unwrap();
21608        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
21609            panic!("expected Committed, got {:?}", handle.state());
21610        };
21611        (epoch, receipt.commit_ts)
21612    }
21613
21614    #[test]
21615    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
21616        let dir = tempfile::tempdir().unwrap();
21617        let db = Database::create(dir.path()).unwrap();
21618        db.create_table("t", int_pk_schema()).unwrap();
21619
21620        let (epoch, commit_ts) = commit_one(&db);
21621        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
21622        // An epoch no commit sealed misses (callers fall back).
21623        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
21624    }
21625
21626    #[test]
21627    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
21628        let dir = tempfile::tempdir().unwrap();
21629        let (epoch, commit_ts) = {
21630            let db = Database::create(dir.path()).unwrap();
21631            db.create_table("t", int_pk_schema()).unwrap();
21632            commit_one(&db)
21633        };
21634
21635        let db = Database::open(dir.path()).unwrap();
21636        let reconstructed = db
21637            .commit_ts_for_epoch(epoch)
21638            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
21639        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
21640        // The ledger byte format stores micros only (spec §8.1): the logical
21641        // counter and tiebreaker reconstruct as 0.
21642        assert_eq!(reconstructed.logical, 0);
21643        assert_eq!(reconstructed.node_tiebreaker, 0);
21644    }
21645
21646    #[test]
21647    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
21648        use crate::wal::Op;
21649        let records = vec![
21650            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
21651            crate::wal::Record::new(
21652                Epoch(2),
21653                7,
21654                Op::TxnCommit {
21655                    epoch: 41,
21656                    added_runs: vec![],
21657                },
21658            ),
21659            // No CommitTimestamp for txn 8: not reconstructible.
21660            crate::wal::Record::new(
21661                Epoch(3),
21662                8,
21663                Op::TxnCommit {
21664                    epoch: 42,
21665                    added_runs: vec![],
21666                },
21667            ),
21668            // Timestamp without a commit marker: aborted, not reconstructible.
21669            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
21670        ];
21671        let ledger = commit_ts_ledger_from_recovery(&records);
21672        assert_eq!(ledger.len(), 1);
21673        assert_eq!(
21674            ledger.get(&41),
21675            Some(&mongreldb_types::hlc::HlcTimestamp {
21676                physical_micros: 1,
21677                logical: 0,
21678                node_tiebreaker: 0,
21679            })
21680        );
21681    }
21682
21683    #[test]
21684    fn new_writes_always_have_some_commit_ts() {
21685        let dir = tempfile::tempdir().unwrap();
21686        let db = Database::create(dir.path()).unwrap();
21687        db.create_table("t", int_pk_schema()).unwrap();
21688        let mut txn = db.begin();
21689        let state = txn.state_handle();
21690        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
21691        let epoch = txn.commit().unwrap();
21692        let crate::txn::TransactionState::Committed(receipt) = state.state() else {
21693            panic!("expected Committed receipt");
21694        };
21695        let handle = db.table("t").unwrap();
21696        let table = handle.lock();
21697        // Product snapshots are HLC-pinned; epoch-only Snapshot::at hides
21698        // HLC-stamped versions by design (no dual authority).
21699        let (snap, _g) = db.snapshot();
21700        let rows = table.visible_rows(snap).expect("visible rows");
21701        assert_eq!(
21702            rows.len(),
21703            1,
21704            "committed put must be visible under HLC snapshot"
21705        );
21706        assert_eq!(rows[0].commit_ts, Some(receipt.commit_ts));
21707        assert_eq!(db.commit_ts_for_epoch(epoch), Some(receipt.commit_ts));
21708    }
21709
21710    #[test]
21711    fn same_transaction_identical_hlc_on_apply() {
21712        use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21713
21714        let dir = tempfile::tempdir().unwrap();
21715        let db = Database::create_cluster_replica(
21716            dir.path(),
21717            ClusterId::from_bytes([1; 16]),
21718            NodeId::from_bytes([2; 16]),
21719            DatabaseId::from_bytes([3; 16]),
21720        )
21721        .unwrap();
21722        db.apply_replicated_catalog_command(&crate::catalog_cmds::CatalogCommandRecord {
21723            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21724            catalog_version: 1,
21725            command: crate::catalog_cmds::CatalogCommand::CreateTable {
21726                name: "t".into(),
21727                schema: int_pk_schema(),
21728                created_epoch: 1,
21729            },
21730        })
21731        .unwrap();
21732        let table_id = db.table_id("t").unwrap();
21733        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
21734            physical_micros: 42_000,
21735            logical: 3,
21736            node_tiebreaker: 9,
21737        };
21738        // Same decision HLC applied twice (two participants / two apply
21739        // calls) must stamp identical commit_ts on every row version.
21740        let staged = vec![StagedTxnWrite::Put {
21741            table_id,
21742            rows: bincode::serialize(&vec![
21743                Row::new(crate::RowId(1), Epoch(0)).with_column(1, Value::Int64(1))
21744            ])
21745            .unwrap(),
21746        }
21747        .encode()
21748        .unwrap()];
21749        assert!(db
21750            .apply_committed_transaction(1 << 63, commit_ts, &staged)
21751            .unwrap());
21752        let staged2 = vec![StagedTxnWrite::Put {
21753            table_id,
21754            rows: bincode::serialize(&vec![
21755                Row::new(crate::RowId(2), Epoch(0)).with_column(1, Value::Int64(2))
21756            ])
21757            .unwrap(),
21758        }
21759        .encode()
21760        .unwrap()];
21761        assert!(db
21762            .apply_committed_transaction((1 << 63) + 1, commit_ts, &staged2)
21763            .unwrap());
21764        let handle = db.table("t").unwrap();
21765        let table = handle.lock();
21766        let row1 = table
21767            .get(crate::RowId(1), Snapshot::unbounded())
21768            .expect("applied row 1");
21769        let row2 = table
21770            .get(crate::RowId(2), Snapshot::unbounded())
21771            .expect("applied row 2");
21772        // The durable WAL `Put` payload does not carry `Row::commit_ts`
21773        // (0.63.1 bincode layout); rows are restamped from the txn's
21774        // `Op::CommitTimestamp` ledger record, which carries physical micros
21775        // only. Both applies must observe that identical recovery form.
21776        let recovery_form = mongreldb_types::hlc::HlcTimestamp {
21777            physical_micros: commit_ts.physical_micros,
21778            logical: 0,
21779            node_tiebreaker: 0,
21780        };
21781        assert_eq!(row1.commit_ts, Some(recovery_form));
21782        assert_eq!(row2.commit_ts, Some(recovery_form));
21783        assert_eq!(row1.commit_ts, row2.commit_ts);
21784        drop(table);
21785        // Latest applied epoch's ledger entry matches the shared decision HLC
21786        // physical component (logical/tiebreaker may be zero on recovery form).
21787        let epoch = db.visible_epoch();
21788        assert_eq!(
21789            db.commit_ts_for_epoch(epoch).map(|ts| ts.physical_micros),
21790            Some(commit_ts.physical_micros)
21791        );
21792    }
21793
21794    #[test]
21795    fn snapshot_hlc_hides_later_commit_ts_even_if_epoch_higher() {
21796        use mongreldb_types::hlc::HlcTimestamp;
21797        let early = HlcTimestamp {
21798            physical_micros: 100,
21799            logical: 0,
21800            node_tiebreaker: 1,
21801        };
21802        let late = HlcTimestamp {
21803            physical_micros: 200,
21804            logical: 0,
21805            node_tiebreaker: 1,
21806        };
21807        // Snapshot at early HLC with a high epoch budget still hides a later HLC.
21808        let snap = Snapshot::at_hlc(Epoch(99), early);
21809        assert!(!snap.observes_version(Epoch(1), Some(late)));
21810        assert!(snap.observes_version(Epoch(1), Some(early)));
21811        // Live Database snapshots are HLC-pinned.
21812        let dir = tempfile::tempdir().unwrap();
21813        let db = Database::create(dir.path()).unwrap();
21814        let (snap, _g) = db.snapshot();
21815        assert_ne!(
21816            snap.commit_ts,
21817            HlcTimestamp::ZERO,
21818            "Database::snapshot must pin live HLC via at_hlc"
21819        );
21820    }
21821
21822    #[test]
21823    fn hlc_stamped_row_visible_at_hlc_snapshot_not_epoch_only() {
21824        let dir = tempfile::tempdir().unwrap();
21825        let db = Database::create(dir.path()).unwrap();
21826        db.create_table("t", int_pk_schema()).unwrap();
21827        let (_epoch, commit_ts) = commit_one(&db);
21828        assert_ne!(commit_ts, mongreldb_types::hlc::HlcTimestamp::ZERO);
21829
21830        let handle = db.table("t").unwrap();
21831        let table = handle.lock();
21832        // (a) HLC-stamped row visible at an HLC-pinned snapshot.
21833        let hlc_snap = Snapshot::at_hlc(Epoch(u64::MAX), commit_ts);
21834        let rows = table.visible_rows(hlc_snap).expect("visible");
21835        assert_eq!(rows.len(), 1);
21836        assert_eq!(rows[0].commit_ts, Some(commit_ts));
21837        assert!(table.get(rows[0].row_id, hlc_snap).is_some());
21838
21839        // (b) Epoch-only snapshot still sees HLC-stamped rows by epoch (dual-model).
21840        let legacy = Snapshot::at(Epoch(u64::MAX));
21841        assert!(!legacy.uses_hlc_authority());
21842        assert_eq!(
21843            table.visible_rows(legacy).expect("visible").len(),
21844            1,
21845            "epoch pin sees HLC-stamped rows by epoch during dual-model migration"
21846        );
21847        assert!(table.get(rows[0].row_id, legacy).is_some());
21848    }
21849
21850    #[test]
21851    fn hlc_gc_floor_reports_named_sources() {
21852        let dir = tempfile::tempdir().unwrap();
21853        let db = Database::create(dir.path()).unwrap();
21854        db.create_table("t", int_pk_schema()).unwrap();
21855        let (epoch, commit_ts) = commit_one(&db);
21856
21857        // No pins: every HLC source is ZERO.
21858        let empty = db.hlc_gc_floor();
21859        assert_eq!(empty.floor(), mongreldb_types::hlc::HlcTimestamp::ZERO);
21860        assert_eq!(empty.sources().len(), 6);
21861
21862        // Pin via product snapshot (transaction source, epoch-backed).
21863        let (_snap, guard) = db.snapshot();
21864        let with_pin = db.hlc_gc_floor();
21865        // Projection succeeds only when the ledger has a stamp for the pin epoch.
21866        let projected = db.commit_ts_for_epoch(epoch);
21867        if let Some(ts) = projected {
21868            // Snapshot pins the *visible* watermark, which should match the commit.
21869            assert_eq!(with_pin.transaction_snapshot, ts);
21870            assert_eq!(with_pin.floor(), ts);
21871            assert_eq!(ts.physical_micros, commit_ts.physical_micros);
21872        } else {
21873            assert_eq!(
21874                with_pin.transaction_snapshot,
21875                mongreldb_types::hlc::HlcTimestamp::ZERO
21876            );
21877        }
21878        drop(guard);
21879    }
21880}
21881
21882#[cfg(test)]
21883mod stage2e_storage_mode_tests {
21884    use super::*;
21885    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21886    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
21887    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21888
21889    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
21890        (
21891            ClusterId::from_bytes([seed; 16]),
21892            NodeId::from_bytes([seed + 1; 16]),
21893            DatabaseId::from_bytes([seed + 2; 16]),
21894        )
21895    }
21896
21897    fn marker(root: &Path) -> Option<StorageMode> {
21898        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
21899        crate::storage_mode::read(&durable).unwrap()
21900    }
21901
21902    fn simple_schema() -> Schema {
21903        Schema {
21904            columns: vec![ColumnDef {
21905                id: 1,
21906                name: "id".into(),
21907                ty: TypeId::Int64,
21908                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21909                default_value: None,
21910                embedding_source: None,
21911            }],
21912            ..Schema::default()
21913        }
21914    }
21915
21916    #[test]
21917    fn standalone_create_writes_marker_and_reopens() {
21918        let dir = tempfile::tempdir().unwrap();
21919        let root = dir.path().join("db");
21920        let db = Database::create(&root).unwrap();
21921        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21922        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
21923        drop(db);
21924        let db = Database::open(&root).unwrap();
21925        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21926        drop(db);
21927    }
21928
21929    #[test]
21930    fn legacy_database_without_marker_opens_and_gains_marker() {
21931        let dir = tempfile::tempdir().unwrap();
21932        let root = dir.path().join("db");
21933        let db = Database::create(&root).unwrap();
21934        drop(db);
21935        // Simulate a pre-marker database.
21936        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
21937        assert_eq!(marker(&root), None);
21938        let db = Database::open(&root).unwrap();
21939        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21940        drop(db);
21941    }
21942
21943    #[test]
21944    fn server_owned_standalone_opens_embedded() {
21945        let dir = tempfile::tempdir().unwrap();
21946        let root = dir.path().join("db");
21947        let db = Database::create(&root).unwrap();
21948        drop(db);
21949        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
21950        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
21951        let db = Database::open(&root).unwrap();
21952        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
21953        drop(db);
21954    }
21955
21956    #[test]
21957    fn cluster_replica_is_rejected_by_normal_opens() {
21958        let dir = tempfile::tempdir().unwrap();
21959        let root = dir.path().join("db");
21960        let (cluster_id, node_id, database_id) = identity(10);
21961        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21962        assert_eq!(
21963            marker(&root),
21964            Some(StorageMode::ClusterReplica {
21965                cluster_id,
21966                node_id,
21967                database_id,
21968            })
21969        );
21970        drop(db);
21971
21972        let error = Database::open(&root).unwrap_err();
21973        let message = error.to_string();
21974        assert!(
21975            matches!(error, MongrelError::InvalidArgument(_)),
21976            "unexpected error: {message}"
21977        );
21978        assert!(message.contains("cluster node runtime"), "{message}");
21979        assert!(message.contains(&cluster_id.to_hex()), "{message}");
21980        assert!(message.contains(&node_id.to_hex()), "{message}");
21981        assert!(message.contains(&database_id.to_hex()), "{message}");
21982
21983        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
21984        assert!(error.to_string().contains("cluster node runtime"));
21985        // The rejected opens never disturbed the marker.
21986        assert_eq!(
21987            marker(&root),
21988            Some(StorageMode::ClusterReplica {
21989                cluster_id,
21990                node_id,
21991                database_id,
21992            })
21993        );
21994    }
21995
21996    #[test]
21997    fn offline_validation_opens_cluster_replica_read_only() {
21998        let dir = tempfile::tempdir().unwrap();
21999        let root = dir.path().join("db");
22000        let (cluster_id, node_id, database_id) = identity(20);
22001        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
22002        drop(db);
22003
22004        let options = OpenOptions::default().with_offline_validation(true);
22005        let db = Database::open_with_options(&root, options).unwrap();
22006        assert!(db.is_read_only_replica());
22007        let error = db.create_table("t", simple_schema()).unwrap_err();
22008        assert!(matches!(error, MongrelError::ReadOnlyReplica));
22009        drop(db);
22010        // Offline validation leaves the marker exactly as found.
22011        assert_eq!(
22012            marker(&root),
22013            Some(StorageMode::ClusterReplica {
22014                cluster_id,
22015                node_id,
22016                database_id,
22017            })
22018        );
22019    }
22020
22021    #[test]
22022    fn cluster_runtime_open_requires_exact_identity() {
22023        let dir = tempfile::tempdir().unwrap();
22024        let root = dir.path().join("db");
22025        let (cluster_id, node_id, database_id) = identity(30);
22026        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
22027        drop(db);
22028
22029        // A non-ClusterReplica expectation is a caller error.
22030        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
22031        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22032        // Wrong database identity fails closed.
22033        let wrong = StorageMode::ClusterReplica {
22034            cluster_id,
22035            node_id,
22036            database_id: DatabaseId::from_bytes([99; 16]),
22037        };
22038        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
22039        assert!(error.to_string().contains("identity mismatch"), "{error}");
22040        // A legacy database without a marker is not a cluster replica.
22041        let legacy = dir.path().join("legacy");
22042        let legacy_db = Database::create(&legacy).unwrap();
22043        drop(legacy_db);
22044        let expected = StorageMode::ClusterReplica {
22045            cluster_id,
22046            node_id,
22047            database_id,
22048        };
22049        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
22050        assert!(error.to_string().contains("identity mismatch"), "{error}");
22051
22052        // The matching identity opens; user writes are rejected (writes
22053        // arrive through the replicated apply path only).
22054        let db = Database::open_cluster_replica(&root, &expected).unwrap();
22055        assert!(db.is_read_only_replica());
22056        let error = db.create_table("t", simple_schema()).unwrap_err();
22057        assert!(matches!(error, MongrelError::ReadOnlyReplica));
22058        drop(db);
22059    }
22060}
22061
22062#[cfg(test)]
22063mod stage2e_replicated_apply_tests {
22064    use super::*;
22065    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
22066    use crate::memtable::{Row, Value};
22067    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
22068    use crate::wal::{Op, Record};
22069    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
22070    use std::sync::Arc;
22071
22072    fn ids() -> (ClusterId, NodeId, DatabaseId) {
22073        (
22074            ClusterId::from_bytes([1; 16]),
22075            NodeId::from_bytes([2; 16]),
22076            DatabaseId::from_bytes([3; 16]),
22077        )
22078    }
22079
22080    fn expected_mode() -> crate::storage_mode::StorageMode {
22081        let (cluster_id, node_id, database_id) = ids();
22082        crate::storage_mode::StorageMode::ClusterReplica {
22083            cluster_id,
22084            node_id,
22085            database_id,
22086        }
22087    }
22088
22089    fn simple_schema() -> Schema {
22090        Schema {
22091            columns: vec![ColumnDef {
22092                id: 1,
22093                name: "id".into(),
22094                ty: TypeId::Int64,
22095                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22096                default_value: None,
22097                embedding_source: None,
22098            }],
22099            ..Schema::default()
22100        }
22101    }
22102
22103    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22104        CatalogCommandRecord {
22105            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22106            catalog_version,
22107            command: CatalogCommand::CreateTable {
22108                name: name.to_string(),
22109                schema: simple_schema(),
22110                created_epoch: 1,
22111            },
22112        }
22113    }
22114
22115    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22116        let rows: Vec<Row> = values
22117            .iter()
22118            .map(|value| {
22119                // Distinct row ids per value so batches never overwrite each
22120                // other's MVCC versions.
22121                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22122                    .with_column(1, Value::Int64(*value))
22123            })
22124            .collect();
22125        vec![
22126            Record::new(
22127                Epoch(0),
22128                txn_id,
22129                Op::Put {
22130                    table_id,
22131                    rows: bincode::serialize(&rows).unwrap(),
22132                },
22133            ),
22134            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22135            Record::new(
22136                Epoch(0),
22137                txn_id,
22138                Op::TxnCommit {
22139                    epoch,
22140                    added_runs: Vec::new(),
22141                },
22142            ),
22143        ]
22144    }
22145
22146    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22147        let handle = db.table(table).unwrap();
22148        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22149        let snap = db.visible_snapshot();
22150        let rows = handle.lock().visible_rows(snap).unwrap();
22151        let mut values: Vec<i64> = rows
22152            .iter()
22153            .map(|row| match row.columns.get(&1) {
22154                Some(Value::Int64(value)) => *value,
22155                other => panic!("unexpected column: {other:?}"),
22156            })
22157            .collect();
22158        values.sort_unstable();
22159        values
22160    }
22161
22162    #[test]
22163    fn catalog_command_mounts_table_and_replays_as_noop() {
22164        let dir = tempfile::tempdir().unwrap();
22165        let (cluster_id, node_id, database_id) = ids();
22166        let db =
22167            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22168
22169        let record = create_table_record("items", 1);
22170        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22171        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
22172        assert_eq!(db.table_names(), vec!["items".to_string()]);
22173        assert_eq!(db.catalog_version(), 1);
22174
22175        // Idempotent replay of the same record.
22176        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22177        assert!(matches!(delta, CatalogDelta::NoOp));
22178        assert_eq!(db.table_names().len(), 1);
22179        drop(db);
22180
22181        // The command was checkpointed: the table survives reopen.
22182        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22183        assert_eq!(db.table_names(), vec!["items".to_string()]);
22184        assert_eq!(db.catalog_version(), 1);
22185    }
22186
22187    #[test]
22188    fn records_apply_rows_and_skip_replays_across_restart() {
22189        let dir = tempfile::tempdir().unwrap();
22190        let (cluster_id, node_id, database_id) = ids();
22191        let db =
22192            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22193        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22194            .unwrap();
22195
22196        let records = put_records(1, 0, 2, &[10, 20, 30]);
22197        assert!(db.apply_replicated_records(&records).unwrap());
22198        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22199        assert_eq!(db.visible_epoch(), Epoch(2));
22200
22201        // Crash-window redelivery of the same committed transaction is a
22202        // side-effect-free replay.
22203        assert!(!db.apply_replicated_records(&records).unwrap());
22204        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22205
22206        // A later transaction at a higher epoch still applies.
22207        let later = put_records(2, 0, 3, &[40]);
22208        assert!(db.apply_replicated_records(&later).unwrap());
22209        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22210        let db = Arc::new(db);
22211        db.shutdown().unwrap();
22212
22213        // Restart: the local WAL replays the applied rows, and the state
22214        // machine's redelivery is recognized as a replay — no double-apply.
22215        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22216        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22217        assert!(!db.apply_replicated_records(&later).unwrap());
22218        assert!(!db.apply_replicated_records(&records).unwrap());
22219        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22220    }
22221
22222    #[test]
22223    fn spilled_run_commits_fail_closed_this_wave() {
22224        let dir = tempfile::tempdir().unwrap();
22225        let (cluster_id, node_id, database_id) = ids();
22226        let db =
22227            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22228        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22229            .unwrap();
22230        let mut records = put_records(1, 0, 2, &[10]);
22231        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22232            panic!("put_records ends in TxnCommit");
22233        };
22234        added_runs.push(crate::wal::AddedRun {
22235            table_id: 0,
22236            run_id: 7,
22237            row_count: 1,
22238            level: 0,
22239            min_row_id: 1,
22240            max_row_id: 1,
22241            content_hash: [0; 32],
22242        });
22243        let error = db.apply_replicated_records(&records).unwrap_err();
22244        assert!(
22245            error.to_string().contains("spilled-run"),
22246            "unexpected error: {error}"
22247        );
22248        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22249    }
22250
22251    #[test]
22252    fn records_without_commit_marker_fail_closed() {
22253        let dir = tempfile::tempdir().unwrap();
22254        let (cluster_id, node_id, database_id) = ids();
22255        let db =
22256            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22257        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22258            .unwrap();
22259        let mut records = put_records(1, 0, 2, &[10]);
22260        records.pop(); // strip the TxnCommit
22261        let error = db.apply_replicated_records(&records).unwrap_err();
22262        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22263        assert!(db.apply_replicated_records(&[]).is_err());
22264        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22265    }
22266}
22267
22268#[cfg(test)]
22269mod stage2c_spill_translation_tests {
22270    use super::*;
22271    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
22272    use crate::memtable::{Row, Value};
22273    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
22274    use crate::wal::{Op, Record};
22275    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
22276
22277    fn simple_schema() -> Schema {
22278        Schema {
22279            columns: vec![ColumnDef {
22280                id: 1,
22281                name: "id".into(),
22282                ty: TypeId::Int64,
22283                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22284                default_value: None,
22285                embedding_source: None,
22286            }],
22287            ..Schema::default()
22288        }
22289    }
22290
22291    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22292        CatalogCommandRecord {
22293            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22294            catalog_version,
22295            command: CatalogCommand::CreateTable {
22296                name: name.to_string(),
22297                schema: simple_schema(),
22298                created_epoch: 1,
22299            },
22300        }
22301    }
22302
22303    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22304        let handle = db.table(table).unwrap();
22305        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22306        let snap = db.visible_snapshot();
22307        let rows = handle.lock().visible_rows(snap).unwrap();
22308        let mut values: Vec<i64> = rows
22309            .iter()
22310            .map(|row| match row.columns.get(&1) {
22311                Some(Value::Int64(value)) => *value,
22312                other => panic!("unexpected column: {other:?}"),
22313            })
22314            .collect();
22315        values.sort_unstable();
22316        values
22317    }
22318
22319    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22320        let rows: Vec<Row> = values
22321            .iter()
22322            .map(|value| {
22323                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22324                    .with_column(1, Value::Int64(*value))
22325            })
22326            .collect();
22327        vec![
22328            Record::new(
22329                Epoch(0),
22330                txn_id,
22331                Op::Put {
22332                    table_id,
22333                    rows: bincode::serialize(&rows).unwrap(),
22334                },
22335            ),
22336            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22337            Record::new(
22338                Epoch(0),
22339                txn_id,
22340                Op::TxnCommit {
22341                    epoch,
22342                    added_runs: Vec::new(),
22343                },
22344            ),
22345        ]
22346    }
22347
22348    fn added_run(
22349        table_id: u64,
22350        row_count: u64,
22351        min_row_id: u64,
22352        max_row_id: u64,
22353    ) -> crate::wal::AddedRun {
22354        crate::wal::AddedRun {
22355            table_id,
22356            run_id: 7,
22357            row_count,
22358            level: 0,
22359            min_row_id,
22360            max_row_id,
22361            content_hash: [0; 32],
22362        }
22363    }
22364
22365    /// Replays the shared WAL of `db` and returns every record of the one
22366    /// transaction whose commit marker links spilled runs.
22367    fn spilled_commit_records(db: &Database) -> Vec<Record> {
22368        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
22369        let txn_id = records
22370            .iter()
22371            .find_map(|record| match &record.op {
22372                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
22373                _ => None,
22374            })
22375            .expect("a spilled commit is present in the WAL");
22376        records
22377            .into_iter()
22378            .filter(|record| record.txn_id == txn_id)
22379            .collect()
22380    }
22381
22382    #[test]
22383    fn non_spilled_records_translate_byte_identical() {
22384        let records = put_records(1, 0, 2, &[10, 20, 30]);
22385        let translated = translate_records_for_replication(&records).unwrap();
22386        assert_eq!(
22387            bincode::serialize(&translated).unwrap(),
22388            bincode::serialize(&records).unwrap(),
22389            "a commit without spill links must pass through byte-identical"
22390        );
22391    }
22392
22393    #[test]
22394    fn translation_rejects_uncovered_or_malformed_spills() {
22395        // added_runs with no logical spill records at all: rejected.
22396        let mut records = put_records(1, 0, 2, &[10]);
22397        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22398            panic!("put_records ends in TxnCommit");
22399        };
22400        added_runs.push(added_run(0, 1, 10, 10));
22401        let error = translate_records_for_replication(&records).unwrap_err();
22402        assert!(
22403            error.to_string().contains("no logical row records"),
22404            "unexpected error: {error}"
22405        );
22406
22407        // Coverage present but short of the linked row count: rejected.
22408        let mut records = put_records(1, 0, 2, &[10]);
22409        let spilled: Vec<Row> = (0..3_u64)
22410            .map(|value| {
22411                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
22412            })
22413            .collect();
22414        records.insert(
22415            0,
22416            Record::new(
22417                Epoch(0),
22418                1,
22419                Op::SpilledRows {
22420                    table_id: 0,
22421                    rows: bincode::serialize(&spilled).unwrap(),
22422                },
22423            ),
22424        );
22425        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22426            panic!("put_records ends in TxnCommit");
22427        };
22428        added_runs.push(added_run(0, 4, 0, 3));
22429        let error = translate_records_for_replication(&records).unwrap_err();
22430        assert!(
22431            error.to_string().contains("cover 3 rows"),
22432            "unexpected error: {error}"
22433        );
22434
22435        // An undecodable spill payload: rejected at propose time, never at apply.
22436        let mut records = put_records(1, 0, 2, &[10]);
22437        records.insert(
22438            0,
22439            Record::new(
22440                Epoch(0),
22441                1,
22442                Op::SpilledRows {
22443                    table_id: 0,
22444                    rows: vec![0xFF, 0x01, 0x02],
22445                },
22446            ),
22447        );
22448        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22449            panic!("put_records ends in TxnCommit");
22450        };
22451        added_runs.push(added_run(0, 1, 0, 0));
22452        assert!(translate_records_for_replication(&records).is_err());
22453
22454        // Structural violations mirror the apply-side contract.
22455        assert!(translate_records_for_replication(&[]).is_err());
22456        let mut mixed = put_records(1, 0, 2, &[10]);
22457        mixed[0].txn_id = 99;
22458        assert!(translate_records_for_replication(&mixed).is_err());
22459        let mut no_commit = put_records(1, 0, 2, &[10]);
22460        no_commit.pop();
22461        assert!(translate_records_for_replication(&no_commit).is_err());
22462    }
22463
22464    #[test]
22465    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
22466        // A real standalone commit that spills (spec section 8.5).
22467        let leader_dir = tempfile::tempdir().unwrap();
22468        let leader = Database::create(leader_dir.path()).unwrap();
22469        leader.create_table("t", simple_schema()).unwrap();
22470        leader.set_spill_threshold(1);
22471        let table_id = leader.table_id("t").unwrap();
22472        let values: Vec<i64> = (0..60).collect();
22473        leader
22474            .transaction(|txn| {
22475                for value in &values {
22476                    txn.put("t", vec![(1, Value::Int64(*value))])?;
22477                }
22478                Ok(())
22479            })
22480            .unwrap();
22481        assert_eq!(visible_ids(&leader, "t"), values);
22482
22483        // The leader's own WAL keeps the spill shape: SpilledRows records
22484        // plus an added_runs commit marker.
22485        let records = spilled_commit_records(&leader);
22486        assert!(records
22487            .iter()
22488            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22489        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
22490            panic!("a commit sequence ends in TxnCommit");
22491        };
22492        assert!(!added_runs.is_empty());
22493        let commit_epoch = *epoch;
22494
22495        // Translation strips every run reference and keeps the rows as
22496        // logical puts; the input sequence is untouched.
22497        let translated = translate_records_for_replication(&records).unwrap();
22498        assert!(records
22499            .iter()
22500            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22501        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
22502            panic!("a commit sequence ends in TxnCommit");
22503        };
22504        assert!(!added_runs.is_empty(), "input records must be unchanged");
22505        assert_eq!(translated.len(), records.len());
22506        assert!(translated
22507            .iter()
22508            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
22509        assert!(translated
22510            .iter()
22511            .any(|record| matches!(record.op, Op::Put { .. })));
22512        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
22513            panic!("a commit sequence ends in TxnCommit");
22514        };
22515        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
22516        assert_eq!(*epoch, commit_epoch);
22517
22518        // The translated payload applies on a replica with identical rows.
22519        let replica_dir = tempfile::tempdir().unwrap();
22520        let replica = Database::create_cluster_replica(
22521            replica_dir.path(),
22522            ClusterId::from_bytes([1; 16]),
22523            NodeId::from_bytes([2; 16]),
22524            DatabaseId::from_bytes([3; 16]),
22525        )
22526        .unwrap();
22527        replica
22528            .apply_replicated_catalog_command(&create_table_record("t", 1))
22529            .unwrap();
22530        assert_eq!(replica.table_id("t").unwrap(), table_id);
22531        assert!(replica.apply_replicated_records(&translated).unwrap());
22532        assert_eq!(visible_ids(&replica, "t"), values);
22533        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
22534
22535        // Standalone behavior is unchanged: the leader still recovers its
22536        // spilled commit by linking the run file.
22537        drop(leader);
22538        let leader = Database::open(leader_dir.path()).unwrap();
22539        assert_eq!(visible_ids(&leader, "t"), values);
22540    }
22541
22542    #[test]
22543    fn staged_txn_writes_validate_and_apply() {
22544        let dir = tempfile::tempdir().unwrap();
22545        let db = Database::create_cluster_replica(
22546            dir.path(),
22547            ClusterId::from_bytes([1; 16]),
22548            NodeId::from_bytes([2; 16]),
22549            DatabaseId::from_bytes([3; 16]),
22550        )
22551        .unwrap();
22552        db.apply_replicated_catalog_command(&create_table_record("t", 1))
22553            .unwrap();
22554        let table_id = db.table_id("t").unwrap();
22555
22556        // Malformed payloads and unmounted tables are rejected at prepare.
22557        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
22558        let unknown_table = StagedTxnWrite::Put {
22559            table_id: 99,
22560            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
22561        }
22562        .encode()
22563        .unwrap();
22564        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
22565        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
22566            .iter()
22567            .map(|value| {
22568                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
22569                    .with_column(1, Value::Int64(*value))];
22570                StagedTxnWrite::Put {
22571                    table_id,
22572                    rows: bincode::serialize(&rows).unwrap(),
22573                }
22574                .encode()
22575                .unwrap()
22576            })
22577            .collect();
22578        db.validate_staged_txn_writes(&good).unwrap();
22579
22580        // A committed resolution applies the staged writes; a delete
22581        // resolution removes them; both are replay-safe.
22582        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
22583            physical_micros: 5_000,
22584            logical: 0,
22585            node_tiebreaker: 0,
22586        };
22587        assert!(db
22588            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
22589            .unwrap());
22590        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
22591        let delete = StagedTxnWrite::Delete {
22592            table_id,
22593            row_ids: vec![20],
22594        }
22595        .encode()
22596        .unwrap();
22597        assert!(db
22598            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
22599            .unwrap());
22600        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22601
22602        // Restart: the synthetic WAL transactions replay through the same
22603        // recovery path; the rows are durable.
22604        let db = Arc::new(db);
22605        db.shutdown().unwrap();
22606        let expected = crate::storage_mode::StorageMode::ClusterReplica {
22607            cluster_id: ClusterId::from_bytes([1; 16]),
22608            node_id: NodeId::from_bytes([2; 16]),
22609            database_id: DatabaseId::from_bytes([3; 16]),
22610        };
22611        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
22612        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22613    }
22614}