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::{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            })
974            .collect::<Vec<_>>();
975        let mut total = 0;
976        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
977        assert!(matches!(
978            error,
979            MongrelError::ResourceLimitExceeded {
980                resource: "spilled WAL transaction bytes",
981                ..
982            }
983        ));
984    }
985}
986
987/// Move spill files to their final names before the WAL commit. Dropping this
988/// guard restores pending names while commit is still known not to have begun.
989/// It is disarmed immediately before the first WAL append, where the outcome
990/// can become ambiguous and recovery may need the final names.
991struct PreparedRunLinks {
992    links: Vec<(PathBuf, PathBuf)>,
993    armed: bool,
994}
995
996impl PreparedRunLinks {
997    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
998        let mut guard = Self {
999            links: Vec::with_capacity(spilled.len()),
1000            armed: true,
1001        };
1002        for run in spilled {
1003            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
1004            guard
1005                .links
1006                .push((run.pending_path.clone(), run.final_path.clone()));
1007        }
1008        Ok(guard)
1009    }
1010
1011    fn disarm(&mut self) {
1012        self.armed = false;
1013        for (pending, _) in &self.links {
1014            if let Some(parent) = pending.parent() {
1015                let _ = std::fs::remove_dir_all(parent);
1016            }
1017        }
1018    }
1019}
1020
1021impl Drop for PreparedRunLinks {
1022    fn drop(&mut self) {
1023        if !self.armed {
1024            return;
1025        }
1026        for (pending, final_path) in self.links.iter().rev() {
1027            let _ = std::fs::rename(final_path, pending);
1028        }
1029    }
1030}
1031
1032struct TableApplyBatch {
1033    table_id: u64,
1034    handle: TableHandle,
1035    ops: Vec<crate::txn::StagedOp>,
1036}
1037
1038#[derive(Debug, Clone)]
1039struct TriggerRowImage {
1040    columns: HashMap<u16, Value>,
1041}
1042
1043impl TriggerRowImage {
1044    fn from_row(row: crate::memtable::Row) -> Self {
1045        Self {
1046            columns: row.columns,
1047        }
1048    }
1049
1050    fn from_cells(cells: &[(u16, Value)]) -> Self {
1051        Self {
1052            columns: cells.iter().cloned().collect(),
1053        }
1054    }
1055}
1056
1057#[derive(Debug, Clone)]
1058struct WriteEvent {
1059    table: String,
1060    kind: TriggerEvent,
1061    old: Option<TriggerRowImage>,
1062    new: Option<TriggerRowImage>,
1063    changed_columns: Vec<u16>,
1064    op_indices: Vec<usize>,
1065    put_idx: Option<usize>,
1066    trigger_stack: Vec<String>,
1067}
1068
1069#[derive(Default)]
1070struct TriggerExpansion {
1071    before: Vec<(u64, crate::txn::Staged)>,
1072    before_stacks: Vec<Vec<String>>,
1073    before_external: Vec<ExternalTriggerWrite>,
1074    after: Vec<(u64, crate::txn::Staged)>,
1075    after_stacks: Vec<Vec<String>>,
1076    after_external: Vec<ExternalTriggerWrite>,
1077    ignored_indices: std::collections::BTreeSet<usize>,
1078}
1079
1080#[derive(Clone, PartialEq)]
1081struct TriggerCatalogBinding {
1082    triggers: Vec<TriggerEntry>,
1083    tables: Vec<(String, u64, u64)>,
1084    external_tables: Vec<(String, u64, u64)>,
1085}
1086
1087fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
1088    let mut triggers = catalog
1089        .triggers
1090        .iter()
1091        .filter(|entry| entry.trigger.enabled)
1092        .cloned()
1093        .collect::<Vec<_>>();
1094    if triggers.is_empty() {
1095        return None;
1096    }
1097    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
1098    let mut tables = catalog
1099        .tables
1100        .iter()
1101        .filter(|entry| matches!(entry.state, TableState::Live))
1102        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
1103        .collect::<Vec<_>>();
1104    tables.sort_unstable();
1105    let mut external_tables = catalog
1106        .external_tables
1107        .iter()
1108        .map(|entry| {
1109            (
1110                entry.name.clone(),
1111                entry.created_epoch,
1112                entry.declared_schema.schema_id,
1113            )
1114        })
1115        .collect::<Vec<_>>();
1116    external_tables.sort_unstable();
1117    Some(TriggerCatalogBinding {
1118        triggers,
1119        tables,
1120        external_tables,
1121    })
1122}
1123
1124struct TriggerProgramOutput<'a> {
1125    added: &'a mut Vec<(u64, crate::txn::Staged)>,
1126    added_stacks: &'a mut Vec<Vec<String>>,
1127    added_external: &'a mut Vec<ExternalTriggerWrite>,
1128    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
1129}
1130
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132enum TriggerProgramOutcome {
1133    Continue,
1134    Ignore,
1135}
1136
1137/// An integrity issue found by [`Database::check`] (spec §16).
1138#[derive(Debug, Clone)]
1139pub struct CheckIssue {
1140    pub table_id: u64,
1141    pub table_name: String,
1142    pub severity: String,
1143    pub description: String,
1144}
1145
1146/// One optimistic authorization snapshot for a complete scored read.
1147#[derive(Debug, Clone)]
1148pub struct AuthorizedReadSnapshot {
1149    pub table: String,
1150    pub table_snapshot: Snapshot,
1151    pub data_generation: u64,
1152    pub security_version: u64,
1153    pub allowed_row_ids: Option<HashSet<RowId>>,
1154}
1155
1156/// Exact table/security generation used by one successful authorized read.
1157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1158pub struct AuthorizedReadStamp {
1159    pub table_id: u64,
1160    pub schema_id: u64,
1161    pub data_generation: u64,
1162    pub security_version: u64,
1163    pub snapshot: Snapshot,
1164}
1165
1166type RlsCacheKey = (String, u64, u64, String);
1167
1168/// Runtime statistics for the byte-bounded RLS candidate cache.
1169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1170pub struct RlsCacheStats {
1171    pub entries: usize,
1172    pub bytes: usize,
1173    pub hits: u64,
1174    pub misses: u64,
1175    pub evictions: u64,
1176    pub build_nanos: u64,
1177    pub rows_evaluated: u64,
1178}
1179
1180const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
1181const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
1182const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
1183const CDC_MAX_EVENTS: usize = 100_000;
1184const CDC_MAX_ROWS: usize = 1_000_000;
1185const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
1186const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
1187
1188fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
1189    let requested = total.saturating_add(amount);
1190    if requested > CDC_MAX_RETAINED_BYTES {
1191        return Err(MongrelError::ResourceLimitExceeded {
1192            resource,
1193            requested,
1194            limit: CDC_MAX_RETAINED_BYTES,
1195        });
1196    }
1197    *total = requested;
1198    Ok(())
1199}
1200
1201fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
1202    usize::try_from(row.estimated_bytes())
1203        .unwrap_or(usize::MAX)
1204        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
1205}
1206
1207fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
1208    let value_slot = std::mem::size_of::<serde_json::Value>();
1209    row.columns.values().fold(512_usize, |bytes, value| {
1210        let values = match value {
1211            Value::Bytes(values) => values.len(),
1212            Value::Json(values) => values.len(),
1213            Value::Embedding(values) => values.len(),
1214            Value::GeneratedEmbedding(value) => value.vector.len(),
1215            _ => 1,
1216        };
1217        bytes.saturating_add(values.saturating_mul(value_slot))
1218    })
1219}
1220
1221fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
1222    rows.iter().fold(0_usize, |bytes, row| {
1223        bytes.saturating_add(cdc_row_json_bytes(row))
1224    })
1225}
1226
1227#[derive(Default)]
1228struct RlsCache {
1229    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
1230    lru: VecDeque<RlsCacheKey>,
1231    bytes: usize,
1232    hits: u64,
1233    misses: u64,
1234    evictions: u64,
1235    build_nanos: u64,
1236    rows_evaluated: u64,
1237}
1238
1239impl RlsCache {
1240    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
1241        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
1242        if value.is_some() {
1243            self.hits = self.hits.saturating_add(1);
1244            self.touch(key);
1245        } else {
1246            self.misses = self.misses.saturating_add(1);
1247        }
1248        value
1249    }
1250
1251    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
1252        let bytes = key
1253            .0
1254            .len()
1255            .saturating_add(key.3.len())
1256            .saturating_add(
1257                value
1258                    .capacity()
1259                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
1260            )
1261            .saturating_add(std::mem::size_of::<RlsCacheKey>());
1262        if bytes > RLS_CACHE_MAX_BYTES {
1263            return;
1264        }
1265        if let Some((_, old_bytes)) = self.entries.remove(&key) {
1266            self.bytes = self.bytes.saturating_sub(old_bytes);
1267        }
1268        self.lru.retain(|candidate| candidate != &key);
1269        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
1270            let Some(oldest) = self.lru.pop_front() else {
1271                break;
1272            };
1273            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
1274                self.bytes = self.bytes.saturating_sub(old_bytes);
1275                self.evictions = self.evictions.saturating_add(1);
1276            }
1277        }
1278        self.bytes = self.bytes.saturating_add(bytes);
1279        self.lru.push_back(key.clone());
1280        self.entries.insert(key, (value, bytes));
1281    }
1282
1283    fn touch(&mut self, key: &RlsCacheKey) {
1284        self.lru.retain(|candidate| candidate != key);
1285        self.lru.push_back(key.clone());
1286    }
1287
1288    fn stats(&self) -> RlsCacheStats {
1289        RlsCacheStats {
1290            entries: self.entries.len(),
1291            bytes: self.bytes,
1292            hits: self.hits,
1293            misses: self.misses,
1294            evictions: self.evictions,
1295            build_nanos: self.build_nanos,
1296            rows_evaluated: self.rows_evaluated,
1297        }
1298    }
1299}
1300
1301/// Mounted table with immutable, structurally shared scored-read generations.
1302#[derive(Clone)]
1303pub struct TableHandle {
1304    inner: TableHandleInner,
1305    generation_metrics: Arc<TableGenerationMetrics>,
1306}
1307
1308#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1309pub struct TableGenerationStats {
1310    pub active_read_generations: usize,
1311    pub max_live_read_generations: usize,
1312    pub cow_clone_count: u64,
1313    pub cow_clone_nanos: u64,
1314    pub estimated_cow_clone_bytes: u64,
1315    pub writer_wait_nanos: u64,
1316}
1317
1318#[derive(Default)]
1319#[doc(hidden)]
1320pub struct TableGenerationMetrics {
1321    active_read_generations: AtomicUsize,
1322    max_live_read_generations: AtomicUsize,
1323    cow_clone_count: AtomicU64,
1324    cow_clone_nanos: AtomicU64,
1325    estimated_cow_clone_bytes: AtomicU64,
1326    writer_wait_nanos: AtomicU64,
1327}
1328
1329impl TableGenerationMetrics {
1330    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
1331        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
1332        self.max_live_read_generations
1333            .fetch_max(active, Ordering::Relaxed);
1334        Arc::new(TableReadGeneration {
1335            table,
1336            metrics: Arc::clone(self),
1337        })
1338    }
1339
1340    fn stats(&self) -> TableGenerationStats {
1341        TableGenerationStats {
1342            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
1343            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
1344            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
1345            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
1346            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
1347            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
1348        }
1349    }
1350}
1351
1352/// Immutable, structurally shared snapshot used by scored readers.
1353pub struct TableReadGeneration {
1354    table: Table,
1355    metrics: Arc<TableGenerationMetrics>,
1356}
1357
1358impl std::ops::Deref for TableReadGeneration {
1359    type Target = Table;
1360
1361    fn deref(&self) -> &Self::Target {
1362        &self.table
1363    }
1364}
1365
1366impl Drop for TableReadGeneration {
1367    fn drop(&mut self) {
1368        self.metrics
1369            .active_read_generations
1370            .fetch_sub(1, Ordering::Relaxed);
1371    }
1372}
1373
1374#[derive(Clone)]
1375enum TableHandleInner {
1376    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
1377    Direct(Arc<Mutex<Table>>),
1378}
1379
1380pub enum TableGuard<'a> {
1381    CopyOnWrite {
1382        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
1383        metrics: Arc<TableGenerationMetrics>,
1384    },
1385    Direct {
1386        table: parking_lot::MutexGuard<'a, Table>,
1387    },
1388}
1389
1390impl TableHandle {
1391    fn new(table: Table) -> Self {
1392        Self {
1393            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
1394            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1395        }
1396    }
1397
1398    pub fn from_table(table: Table) -> Self {
1399        Self::new(table)
1400    }
1401
1402    pub fn lock(&self) -> TableGuard<'_> {
1403        let started = std::time::Instant::now();
1404        let guard = match &self.inner {
1405            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
1406                table: table.write(),
1407                metrics: Arc::clone(&self.generation_metrics),
1408            },
1409            TableHandleInner::Direct(table) => TableGuard::Direct {
1410                table: table.lock(),
1411            },
1412        };
1413        self.generation_metrics.writer_wait_nanos.fetch_add(
1414            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1415            Ordering::Relaxed,
1416        );
1417        guard
1418    }
1419
1420    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
1421        let started = std::time::Instant::now();
1422        let guard = match &self.inner {
1423            TableHandleInner::CopyOnWrite(table) => {
1424                table
1425                    .try_write_for(timeout)
1426                    .map(|table| TableGuard::CopyOnWrite {
1427                        table,
1428                        metrics: Arc::clone(&self.generation_metrics),
1429                    })
1430            }
1431            TableHandleInner::Direct(table) => table
1432                .try_lock_for(timeout)
1433                .map(|table| TableGuard::Direct { table }),
1434        };
1435        self.generation_metrics.writer_wait_nanos.fetch_add(
1436            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1437            Ordering::Relaxed,
1438        );
1439        guard
1440    }
1441
1442    pub fn ptr_eq(&self, other: &Self) -> bool {
1443        match (&self.inner, &other.inner) {
1444            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1445                Arc::ptr_eq(left, right)
1446            }
1447            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1448                Arc::ptr_eq(left, right)
1449            }
1450            _ => false,
1451        }
1452    }
1453
1454    pub fn read_generation_with_context(
1455        &self,
1456        context: Option<&crate::query::AiExecutionContext>,
1457    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1458        let mut table = if let Some(context) = context {
1459            loop {
1460                context.checkpoint()?;
1461                let wait = context
1462                    .remaining_duration()
1463                    .unwrap_or(std::time::Duration::from_millis(5))
1464                    .min(std::time::Duration::from_millis(5));
1465                if let Some(table) = self.try_lock_for(wait) {
1466                    break table;
1467                }
1468            }
1469        } else {
1470            self.lock()
1471        };
1472        let snapshot = table.snapshot();
1473        let generation = table.clone_read_generation()?;
1474        Ok((self.generation_metrics.activate(generation), snapshot))
1475    }
1476
1477    pub fn generation_stats(&self) -> TableGenerationStats {
1478        self.generation_metrics.stats()
1479    }
1480}
1481
1482impl From<Arc<Mutex<Table>>> for TableHandle {
1483    fn from(table: Arc<Mutex<Table>>) -> Self {
1484        Self {
1485            inner: TableHandleInner::Direct(table),
1486            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1487        }
1488    }
1489}
1490
1491impl std::ops::Deref for TableGuard<'_> {
1492    type Target = Table;
1493
1494    fn deref(&self) -> &Self::Target {
1495        match self {
1496            Self::CopyOnWrite { table, .. } => table.as_ref(),
1497            Self::Direct { table } => table,
1498        }
1499    }
1500}
1501
1502impl std::ops::DerefMut for TableGuard<'_> {
1503    fn deref_mut(&mut self) -> &mut Self::Target {
1504        match self {
1505            Self::CopyOnWrite { table, metrics } => {
1506                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1507                    let estimated_bytes = table.estimated_clone_bytes();
1508                    let started = std::time::Instant::now();
1509                    let table = Arc::make_mut(table);
1510                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1511                    metrics.cow_clone_nanos.fetch_add(
1512                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1513                        Ordering::Relaxed,
1514                    );
1515                    metrics
1516                        .estimated_cow_clone_bytes
1517                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1518                    table
1519                } else {
1520                    Arc::make_mut(table)
1521                }
1522            }
1523            Self::Direct { table } => table,
1524        }
1525    }
1526}
1527
1528#[derive(Clone, Debug)]
1529pub struct ReadAuthorization {
1530    pub operation: crate::auth::ColumnOperation,
1531    pub columns: Vec<u16>,
1532    pub permissions: Vec<crate::auth::Permission>,
1533}
1534
1535#[derive(Default, Debug)]
1536struct TableWritePermissionNeeds {
1537    insert: bool,
1538    insert_columns: Vec<u16>,
1539    update: bool,
1540    update_columns: Vec<u16>,
1541    delete: bool,
1542    truncate: bool,
1543}
1544
1545#[cfg(test)]
1546thread_local! {
1547    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1548    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1549    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1550    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1551    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1552    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1553}
1554
1555fn summarize_write_permissions(
1556    staging: &[(u64, crate::txn::Staged)],
1557) -> HashMap<u64, TableWritePermissionNeeds> {
1558    use crate::txn::Staged;
1559
1560    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1561    for (table_id, operation) in staging {
1562        let table = needs.entry(*table_id).or_default();
1563        match operation {
1564            Staged::Put(cells) => {
1565                table.insert = true;
1566                table
1567                    .insert_columns
1568                    .extend(cells.iter().map(|(column, _)| *column));
1569            }
1570            Staged::Update {
1571                changed_columns, ..
1572            } => {
1573                table.update = true;
1574                table.update_columns.extend(changed_columns);
1575            }
1576            Staged::Delete(_) => table.delete = true,
1577            Staged::Truncate => table.truncate = true,
1578        }
1579    }
1580    for table in needs.values_mut() {
1581        table.insert_columns.sort_unstable();
1582        table.insert_columns.dedup();
1583        table.update_columns.sort_unstable();
1584        table.update_columns.dedup();
1585    }
1586    needs
1587}
1588
1589struct SecurityCoordinator {
1590    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1591    gate: RwLock<()>,
1592    version: AtomicU64,
1593}
1594
1595fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1596    static COORDINATORS: std::sync::OnceLock<
1597        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1598    > = std::sync::OnceLock::new();
1599
1600    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1601    let mut coordinators = COORDINATORS
1602        .get_or_init(|| Mutex::new(HashMap::new()))
1603        .lock();
1604    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1605    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1606        return coordinator;
1607    }
1608    let coordinator = Arc::new(SecurityCoordinator {
1609        gate: RwLock::new(()),
1610        version: AtomicU64::new(version),
1611    });
1612    coordinators.insert(root, Arc::downgrade(&coordinator));
1613    coordinator
1614}
1615
1616pub fn lock_table_with_context<'a>(
1617    handle: &'a TableHandle,
1618    context: Option<&crate::query::AiExecutionContext>,
1619) -> Result<TableGuard<'a>> {
1620    let Some(context) = context else {
1621        return Ok(handle.lock());
1622    };
1623    loop {
1624        context.checkpoint()?;
1625        let wait = context
1626            .remaining_duration()
1627            .unwrap_or(std::time::Duration::from_millis(5))
1628            .min(std::time::Duration::from_millis(5));
1629        if let Some(guard) = handle.try_lock_for(wait) {
1630            return Ok(guard);
1631        }
1632    }
1633}
1634
1635/// Knobs for [`Database::open_with_options`].
1636///
1637/// All fields default to the same values the convenience
1638/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1639/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1640#[derive(Clone, Debug, Default)]
1641pub struct OpenOptions {
1642    /// Maximum time, in milliseconds, to wait for the cross-process database
1643    /// lock (`_meta/.lock`) before failing with `MongrelError::DatabaseLocked`.
1644    ///
1645    /// `0` (the default) preserves the historical fail-fast semantics: a
1646    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1647    /// `busy_timeout` semantics kick in once this is non-zero — the open
1648    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1649    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1650    /// point the open returns the same typed lock error as the fail-fast path.
1651    ///
1652    /// Only the cross-process lock is affected. Mounted tables, page-cache
1653    /// misses, and WAL appends already serialize through in-process locks
1654    /// that handle their own contention. A second independent open in the
1655    /// same process always returns `DatabaseLocked` immediately; share the
1656    /// existing `Arc<Database>` instead.
1657    pub lock_timeout_ms: u32,
1658    /// Total bytes the storage core's [`crate::memory::MemoryGovernor`] may
1659    /// hand out across every memory class (S1E-003). `None` (the default)
1660    /// uses [`DEFAULT_MEMORY_BUDGET_BYTES`]. The governor is a reservation
1661    /// accounting layer, not an allocation: this is a cap, not a preallocation.
1662    pub memory_budget_bytes: Option<u64>,
1663    /// Total bytes of live spill files the core's
1664    /// [`crate::spill::SpillManager`] allows across every query (S1E-004).
1665    /// `None` (the default) uses [`DEFAULT_TEMP_DISK_BUDGET_BYTES`]. Again a
1666    /// cap, not a preallocation.
1667    pub temp_disk_budget_bytes: Option<u64>,
1668    /// Open the database through the special offline-validation API (spec
1669    /// section 5.3): any [`crate::storage_mode::StorageMode`] — including
1670    /// `ClusterReplica`, which every normal open path rejects — is opened
1671    /// **read-only** so a backup validator can inspect it. The opened core
1672    /// rejects every write with [`MongrelError::ReadOnlyReplica`]. This is the
1673    /// only way to open a cluster replica outside the cluster node runtime.
1674    pub offline_validation: bool,
1675}
1676
1677impl OpenOptions {
1678    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1679    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1680    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1681        self.lock_timeout_ms = ms;
1682        self
1683    }
1684
1685    /// Set [`OpenOptions::memory_budget_bytes`].
1686    pub fn with_memory_budget_bytes(mut self, bytes: u64) -> Self {
1687        self.memory_budget_bytes = Some(bytes);
1688        self
1689    }
1690
1691    /// Set [`OpenOptions::temp_disk_budget_bytes`].
1692    pub fn with_temp_disk_budget_bytes(mut self, bytes: u64) -> Self {
1693        self.temp_disk_budget_bytes = Some(bytes);
1694        self
1695    }
1696
1697    /// Set [`OpenOptions::offline_validation`]. `true` opens any storage mode
1698    /// read-only (the offline backup-validator API of spec section 5.3).
1699    pub fn with_offline_validation(mut self, offline_validation: bool) -> Self {
1700        self.offline_validation = offline_validation;
1701        self
1702    }
1703}
1704
1705/// How an open/create path treats the durable storage-mode marker (spec
1706/// section 5.3, Stage 2E). Threaded from the public constructors through the
1707/// open chain into [`Database::finish_open`].
1708#[derive(Debug, Clone)]
1709pub(crate) enum OpenModeGate {
1710    /// Normal embedded/server open: `ClusterReplica` markers are rejected with
1711    /// [`crate::storage_mode::StorageModeError::ClusterReplicaRequiresClusterRuntime`].
1712    Normal,
1713    /// Process-local shared core. Authentication binds to each issued handle,
1714    /// so core recovery and table mounting proceed without a caller principal.
1715    SharedCore,
1716    /// Offline backup validator: any mode opens, forced read-only.
1717    OfflineValidation,
1718    /// Cluster node runtime: the marker must exist and equal this
1719    /// `ClusterReplica` identity; the core opens read-only for user paths (all
1720    /// writes arrive through the replicated apply path).
1721    ClusterRuntime {
1722        /// Expected owning cluster.
1723        cluster_id: mongreldb_types::ids::ClusterId,
1724        /// Expected owning node.
1725        node_id: mongreldb_types::ids::NodeId,
1726        /// Expected replicated database.
1727        database_id: mongreldb_types::ids::DatabaseId,
1728    },
1729    /// Fresh create: no marker may exist yet; this mode is written.
1730    Create(crate::storage_mode::StorageMode),
1731}
1732
1733/// Default node-level memory budget (S1E-003): 1 GiB. Comfortably covers the
1734/// two 64 MiB caches with headroom for query execution, result buffering, and
1735/// maintenance reservations.
1736pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
1737
1738/// Default node-level temporary-disk (spill) budget (S1E-004): 4 GiB of live
1739/// spill files across every query on the core.
1740pub const DEFAULT_TEMP_DISK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
1741
1742/// Resolved node-level resource budgets for one storage core
1743/// (S1E-003/S1E-004), threaded from [`OpenOptions`] through the open chain
1744/// into [`Database::finish_open`].
1745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1746pub(crate) struct CoreResourceConfig {
1747    pub memory_budget_bytes: u64,
1748    pub temp_disk_budget_bytes: u64,
1749}
1750
1751impl Default for CoreResourceConfig {
1752    fn default() -> Self {
1753        Self {
1754            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
1755            temp_disk_budget_bytes: DEFAULT_TEMP_DISK_BUDGET_BYTES,
1756        }
1757    }
1758}
1759
1760impl CoreResourceConfig {
1761    fn from_options(options: &OpenOptions) -> Result<Self> {
1762        let config = Self {
1763            memory_budget_bytes: options
1764                .memory_budget_bytes
1765                .unwrap_or(DEFAULT_MEMORY_BUDGET_BYTES),
1766            temp_disk_budget_bytes: options
1767                .temp_disk_budget_bytes
1768                .unwrap_or(DEFAULT_TEMP_DISK_BUDGET_BYTES),
1769        };
1770        if config.memory_budget_bytes == 0 {
1771            return Err(MongrelError::InvalidArgument(
1772                "memory_budget_bytes must be nonzero".into(),
1773            ));
1774        }
1775        if config.temp_disk_budget_bytes == 0 {
1776            return Err(MongrelError::InvalidArgument(
1777                "temp_disk_budget_bytes must be nonzero".into(),
1778            ));
1779        }
1780        Ok(config)
1781    }
1782}
1783
1784/// Per-table version-retention pin diagnostics (S1C-004): one mounted table's
1785/// active pin sources as reported by [`crate::engine::Table::version_pins_report`].
1786#[derive(Debug, Clone)]
1787pub struct TablePinsReport {
1788    /// The mounted table's id.
1789    pub table_id: u64,
1790    /// The mounted table's catalog name.
1791    pub table: String,
1792    /// Every active version-retention pin source on the table.
1793    pub pins: crate::retention::PinsReport,
1794}
1795
1796/// The shared storage core (spec §10.1, S1A-001): one catalog, one epoch
1797/// clock, shared caches, a shared WAL, and a live map of name → `Arc<Table>`.
1798///
1799/// `DatabaseCore` owns every storage resource — the durable root, the
1800/// exclusive lease, the catalog, the commit log, the epoch authority, the
1801/// transaction state, the mounted tables, and the page caches — plus the
1802/// lifecycle state machine (S1A-004). It is shared through an `Arc`: the
1803/// exclusive [`Database`] owner holds one reference, and every
1804/// [`crate::handle::DatabaseHandle`] issued by
1805/// [`crate::manager::DatabaseManager`] holds another. The core deliberately
1806/// does **not** store one mutable "current principal" (spec §4.6): per-caller
1807/// identity lives on the handle types, not inside shared storage state.
1808pub struct DatabaseCore {
1809    root: PathBuf,
1810    durable_root: Arc<crate::durable_file::DurableRoot>,
1811    /// Lifecycle state machine (spec §10.1, S1A-004). Shared through an `Arc`
1812    /// so [`crate::core::OperationGuard`]s are `'static` and thread-safe.
1813    lifecycle: Arc<crate::core::LifecycleController>,
1814    /// Set by [`crate::manager::DatabaseManager`] when this core backs shared
1815    /// handles: lets `shutdown()` transition the registry entry through
1816    /// `Closing` to removal. `None` for exclusively-owned cores.
1817    registry: std::sync::OnceLock<crate::manager::CoreRegistration>,
1818    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1819    read_only: bool,
1820    catalog: RwLock<Catalog>,
1821    security_coordinator: Arc<SecurityCoordinator>,
1822    security_catalog_disk_reads: AtomicU64,
1823    rls_cache: Mutex<RlsCache>,
1824    epoch: Arc<EpochAuthority>,
1825    snapshots: Arc<SnapshotRegistry>,
1826    /// S1E-003: the node-level memory governor for this core. Exactly one per
1827    /// core; both caches below hold reservations under it and are registered
1828    /// as reclaimable subsystems it can evict under pressure (escalation
1829    /// step 2).
1830    memory_governor: crate::memory::MemoryGovernor,
1831    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1832    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1833    /// S1E-004: the node-level spill manager, rooted at `<db-root>/temp/spill`
1834    /// and sealed with the database meta DEK when encryption is enabled. Its
1835    /// startup sweep ran during open; per-query sessions draw from it.
1836    spill_manager: crate::spill::SpillManager,
1837    /// S1E-002: workload resource groups for admission (concurrency, memory,
1838    /// temp disk, work units). Seeded with class defaults at open; operators
1839    /// reconfigure through [`Database::resource_groups`].
1840    resource_groups: crate::resource::ResourceGroupRegistry,
1841    /// Optional pluggable embedding providers (local models / registered
1842    /// backends). Empty by default — dense ANN uses application-supplied
1843    /// vectors; sparse retrieval needs no provider.
1844    embedding_providers: crate::embedding::EmbeddingProviderRegistry,
1845    /// S1F-002: the durable job registry (sibling `JOBS` file). Exactly one
1846    /// per core; jobs recovered mid-`Running` by a crash come back `Paused`.
1847    job_registry: Arc<crate::jobs::JobRegistry>,
1848    commit_lock: Arc<Mutex<()>>,
1849    kms_rotation_lock: Mutex<()>,
1850    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1851    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1852    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1853    /// writes also land in this one WAL (B1 — one WAL per database).
1854    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1855    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1856    /// in P2.7; here it just needs to be unique within an open. Shared with
1857    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1858    next_txn_id: Arc<Mutex<u64>>,
1859    tables: RwLock<HashMap<u64, TableHandle>>,
1860    kek: Option<Arc<crate::encryption::Kek>>,
1861    /// Serializes DDL (create/drop table); data commits serialize through
1862    /// `commit_lock` shared via `SharedCtx`.
1863    ddl_lock: Mutex<()>,
1864    meta_dek: Option<[u8; META_DEK_LEN]>,
1865    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1866    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1867    spill_threshold: std::sync::atomic::AtomicU64,
1868    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1869    /// detection (spec §9.2).
1870    conflicts: crate::txn::ConflictIndex,
1871    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1872    /// pruning (spec §9.2, review fix #12).
1873    active_txns: crate::txn::ActiveTxns,
1874    /// S1B-003: the core's key/predicate lock manager. One per core; commits
1875    /// acquire unique-constraint key claims, FK parent-protection holds,
1876    /// sequence-allocation barriers, and the DML Shared hold on the schema
1877    /// barrier (DDL takes it Exclusive), all released when the transaction
1878    /// ends.
1879    lock_manager: Arc<crate::locks::LockManager>,
1880    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1881    /// Shared with mounted tables so a single-table commit also honors poison.
1882    poisoned: Arc<std::sync::atomic::AtomicBool>,
1883    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1884    /// but defers the fsync to one leader here, so concurrent commits share a
1885    /// single fsync (spec §9.3). Shared with mounted tables.
1886    group: Arc<crate::txn::GroupCommit>,
1887    /// FND-004 (spec §9.4): configured commit authority. Standalone cores bind
1888    /// `StandaloneCommitLog`; cluster runtime replaces it with the tablet
1889    /// group's `RaftCommitLog` after group construction.
1890    commit_log: RwLock<Arc<dyn mongreldb_log::CommitLog>>,
1891    /// Standalone WAL adapter used only by local standalone mutation paths.
1892    /// Cluster replicas reject those paths and receive mutations through
1893    /// committed apply.
1894    standalone_commit_log: Arc<crate::commit_log::StandaloneCommitLog>,
1895    /// The node's single HLC timestamp authority (spec §4.1, §8.2; ADR-0003).
1896    /// Transaction read timestamps are captured at `begin`, and commit
1897    /// timestamps are allocated here under the sequencer lock (strictly after
1898    /// every participant read/write timestamp). `Epoch` remains the
1899    /// reader-visibility counter during the dual-model migration.
1900    hlc: Arc<mongreldb_types::hlc::HlcClock>,
1901    /// S1B-005: durable idempotency ledger for keyed remote writes, persisted
1902    /// in the sibling `TXN_IDEMPOTENCY` file (mirroring `jobs.rs`'s `JOBS`).
1903    idempotency: crate::txn::IdempotencyLedger,
1904    /// Per-open epoch → commit-timestamp ledger backing
1905    /// [`Database::commit_ts_for_epoch`]. Commits sealed within this open
1906    /// record the exact receipt `HlcTimestamp`; commits recovered from the
1907    /// durable `Op::CommitTimestamp` WAL ledger at open reconstruct the
1908    /// physical component only (`logical`/`node_tiebreaker` are 0 — the
1909    /// ledger byte format stores micros). Bounded to the newest
1910    /// [`COMMIT_TS_LEDGER_CAP`] epochs.
1911    commit_ts_ledger: Mutex<std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp>>,
1912    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1913    /// live spill's pending dir (review fix #14, spec §6.4).
1914    active_spills: Arc<crate::retention::ActiveSpills>,
1915    /// A write lock captures a consistent bootstrap image; transaction commits
1916    /// hold a read lock across spill preparation, WAL append, and publish.
1917    replication_barrier: parking_lot::RwLock<()>,
1918    /// Number of rotated WAL segments retained for lagging followers.
1919    replication_wal_retention_segments: AtomicUsize,
1920    /// Live immutable run files used by online backups or scored read
1921    /// generations. GC cannot unlink them until every owning guard drops.
1922    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1923    /// Test-only barrier invoked after a transaction writes its spill runs but
1924    /// before the sequencer/publish, so tests can race `gc()` against an
1925    /// in-flight spill. `None` in production.
1926    #[doc(hidden)]
1927    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1928    /// Test seam after the security read gate is held and before WAL append.
1929    #[doc(hidden)]
1930    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1931    /// Test seam after transaction preparation and before catalog generation
1932    /// validation under the commit sequencer.
1933    #[doc(hidden)]
1934    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1935    /// Test seam after a backup boundary is captured and before pinned runs are
1936    /// copied. Lets tests compact+GC the source at the worst possible moment.
1937    #[doc(hidden)]
1938    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1939    /// Test seam invoked after each successful FK parent-protection lock
1940    /// acquisition inside constraint validation, so tests can rendezvous two
1941    /// committing transactions into a deterministic wait-for cycle. Behind an
1942    /// `Arc` so firing never holds the slot's mutex — concurrent commits (and
1943    /// a hook that itself parks) must not serialize on it.
1944    #[doc(hidden)]
1945    fk_lock_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
1946    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1947    trigger_recursive: AtomicBool,
1948    trigger_max_depth: AtomicU32,
1949    trigger_max_loop_iterations: AtomicU32,
1950    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1951    /// is reconstructed from the WAL by [`Database::change_events_since`].
1952    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1953    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1954    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1955    change_wake: tokio::sync::broadcast::Sender<()>,
1956    /// Final field so every storage resource drops before the exclusive lease.
1957    /// Behind a `Mutex<Option<_>>` so `shutdown()` can release the file lock
1958    /// (spec §10.1, S1A-004 step 7) while handles still reference the core.
1959    _lock: Mutex<Option<ExclusiveDatabaseLease>>,
1960}
1961
1962/// The exclusive public owner of one [`DatabaseCore`] (spec §10.1, S1A-001).
1963///
1964/// `Database::open`/`create*` build exactly one core for a root and reject any
1965/// other core — exclusive or shared — for the same root (spec §2.6). All
1966/// storage state lives on the core; this type adds the owner-bound identity
1967/// context (the cached principal and the shared auth state used by the
1968/// embedded enforcement path). Method calls and field reads transparently
1969/// reach the core through `Deref`, so existing `Database` call sites are
1970/// unchanged.
1971pub struct Database {
1972    core: Arc<DatabaseCore>,
1973    /// The authenticated principal for this owner. `None` on databases
1974    /// opened without credentials (the default — `require_auth = false`),
1975    /// `Some` on credentialed opens. Consulted by every enforcement point
1976    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1977    /// because the access pattern is read-heavy: every `require()` call
1978    /// reads the principal, while writes happen only at open, `enable_auth`,
1979    /// and `refresh_principal`. This matches the engine's existing use of
1980    /// `RwLock` for `catalog` and `tables`.
1981    /// See `docs/15-credential-enforcement.md`.
1982    principal: RwLock<Option<crate::auth::Principal>>,
1983    /// Shared, cloneable handle to the auth state (require_auth flag from the
1984    /// catalog + the principal). Cloned into every mounted `Table` so the
1985    /// Table layer can enforce permissions without holding a reference back
1986    /// to `Database` (which would create a cycle). `AuthState` is already
1987    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1988    auth_state: crate::auth_state::AuthState,
1989    /// `true` when this value is a manager-issued facade over a shared core
1990    /// (Stage 1A): dropping it has no storage side effects, `shutdown()`
1991    /// rejects, and auth-mode transitions are refused so one handle cannot
1992    /// flip the shared core into an enforcement mode other handles cannot
1993    /// observe (fail closed; per-handle enforcement lands with Stage 1D
1994    /// sessions).
1995    shared: bool,
1996}
1997
1998impl std::ops::Deref for Database {
1999    type Target = DatabaseCore;
2000
2001    fn deref(&self) -> &DatabaseCore {
2002        &self.core
2003    }
2004}
2005
2006struct RunPins {
2007    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
2008    runs: Vec<(u64, u128)>,
2009}
2010
2011/// RAII proof that one transaction's lock holds release when the scope that
2012/// acquired them exits (S1B-004 step 12). Used by the commit path (covers
2013/// success, validation failure, and poison alike) and by DDL entry points
2014/// holding the Exclusive schema barrier.
2015struct TxnLockGuard<'a> {
2016    locks: &'a crate::locks::LockManager,
2017    txn_id: u64,
2018}
2019
2020impl Drop for TxnLockGuard<'_> {
2021    fn drop(&mut self) {
2022        self.locks.release_all(self.txn_id);
2023    }
2024}
2025
2026struct BackupFilePins {
2027    root: PathBuf,
2028}
2029
2030struct PendingTableDir {
2031    path: PathBuf,
2032    armed: bool,
2033}
2034
2035impl PendingTableDir {
2036    fn new(path: PathBuf) -> Self {
2037        Self { path, armed: true }
2038    }
2039
2040    fn disarm(&mut self) {
2041        self.armed = false;
2042    }
2043}
2044
2045impl Drop for PendingTableDir {
2046    fn drop(&mut self) {
2047        if self.armed {
2048            let _ = std::fs::remove_dir_all(&self.path);
2049        }
2050    }
2051}
2052
2053impl Drop for BackupFilePins {
2054    fn drop(&mut self) {
2055        let _ = std::fs::remove_dir_all(&self.root);
2056    }
2057}
2058
2059impl Drop for RunPins {
2060    fn drop(&mut self) {
2061        let mut pins = self.pins.lock();
2062        for run in &self.runs {
2063            if let Some(count) = pins.get_mut(run) {
2064                *count -= 1;
2065                if *count == 0 {
2066                    pins.remove(run);
2067                }
2068            }
2069        }
2070    }
2071}
2072
2073/// A durable data-change event reconstructed from committed WAL records, or an
2074/// ephemeral SQL `NOTIFY` event when `id` is `None`.
2075#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2076pub struct ChangeEvent {
2077    pub id: Option<String>,
2078    pub channel: String,
2079    pub table_id: Option<u64>,
2080    pub table: String,
2081    pub op: String,
2082    pub epoch: u64,
2083    pub txn_id: Option<u64>,
2084    pub message: Option<String>,
2085    pub data: Option<serde_json::Value>,
2086}
2087
2088#[derive(Debug, Clone)]
2089pub struct CdcBatch {
2090    pub events: Vec<ChangeEvent>,
2091    pub current_epoch: u64,
2092    pub earliest_epoch: Option<u64>,
2093    pub gap: bool,
2094}
2095
2096/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
2097/// (root, epoch, table count, encryption/auth state) without requiring every
2098/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
2099/// The raw field types carry locks, trait objects, and channels that have no
2100/// useful `Debug` output, so a hand-written impl is clearer than peppering
2101/// `#[allow(dead_code)]` skip attributes across two dozen fields.
2102impl std::fmt::Debug for Database {
2103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2104        let cat = self.catalog.read();
2105        let principal_guard = self.principal.read();
2106        let principal: &str = principal_guard
2107            .as_ref()
2108            .map(|p| p.username.as_str())
2109            .unwrap_or("<none>");
2110        f.debug_struct("Database")
2111            .field("root", &self.root)
2112            .field("db_epoch", &cat.db_epoch)
2113            .field("open_generation", &"sidecar")
2114            .field("tables", &cat.tables.len())
2115            .field("visible_epoch", &self.epoch.visible().0)
2116            .field("encrypted", &self.kek.is_some())
2117            .field("require_auth", &cat.require_auth)
2118            .field("principal", &principal)
2119            .finish()
2120    }
2121}
2122
2123/// Manual `Debug` for `DatabaseCore`, mirroring `Database`'s (see above). The
2124/// core has no cached principal by design (spec §4.6).
2125impl std::fmt::Debug for DatabaseCore {
2126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2127        let cat = self.catalog.read();
2128        f.debug_struct("DatabaseCore")
2129            .field("root", &self.root)
2130            .field("lifecycle", &self.lifecycle.state())
2131            .field("db_epoch", &cat.db_epoch)
2132            .field("tables", &cat.tables.len())
2133            .field("visible_epoch", &self.epoch.visible().0)
2134            .field("encrypted", &self.kek.is_some())
2135            .field("require_auth", &cat.require_auth)
2136            .finish()
2137    }
2138}
2139
2140impl DatabaseCore {
2141    fn ensure_owner_process(&self) -> Result<()> {
2142        let current_pid = std::process::id();
2143        let owner_pid = self
2144            ._lock
2145            .lock()
2146            .as_ref()
2147            .map(|lease| lease.owner_pid)
2148            .unwrap_or(current_pid);
2149        if current_pid == owner_pid {
2150            Ok(())
2151        } else {
2152            Err(MongrelError::ForkedProcess {
2153                owner_pid,
2154                current_pid,
2155            })
2156        }
2157    }
2158
2159    /// The canonical filesystem root this core was opened at.
2160    pub fn root(&self) -> &Path {
2161        self.durable_root.canonical_path()
2162    }
2163
2164    /// The stable directory-handle identity of this core's root (S1A-003).
2165    pub fn file_identity(&self) -> Result<crate::core::DatabaseFileIdentity> {
2166        crate::core::DatabaseFileIdentity::from_durable_root(&self.durable_root)
2167    }
2168
2169    /// The current lifecycle state (S1A-004).
2170    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2171        self.lifecycle.state()
2172    }
2173
2174    /// Whether this core currently admits new operations.
2175    pub fn is_open(&self) -> bool {
2176        self.lifecycle.is_open()
2177    }
2178
2179    /// Admit one operation against this core (S1A-004: "every operation holds
2180    /// an `OperationGuard`"). Rejected unless the core is
2181    /// [`crate::core::LifecycleState::Open`].
2182    pub fn operation_guard(self: &Arc<Self>) -> Result<crate::core::OperationGuard> {
2183        self.lifecycle.begin_operation()
2184    }
2185
2186    /// Register this core with the [`crate::manager::DatabaseManager`] that
2187    /// initialized it (S1A-002). Called once, before the first handle is
2188    /// handed out.
2189    pub(crate) fn set_registry(
2190        &self,
2191        registration: crate::manager::CoreRegistration,
2192    ) -> Result<()> {
2193        self.registry
2194            .set(registration)
2195            .map_err(|_| MongrelError::Conflict("database core is already registry-bound".into()))
2196    }
2197
2198    /// Ordered core shutdown (spec §10.1, S1A-004):
2199    ///
2200    /// 1. Transition `Open` → `Draining`.
2201    /// 2. Reject new sessions and writes (new [`Self::operation_guard`]s fail
2202    ///    from this point).
2203    /// 3. Cancel non-commit-critical queries — a no-op in Stage 1A: the
2204    ///    embedded core has no central query registry yet (Stage 1D adds one),
2205    ///    and every in-flight operation is treated as commit-critical and
2206    ///    drained rather than cancelled.
2207    /// 4. Wait for in-flight operations, up to `drain_deadline`. On timeout
2208    ///    the core stays `Draining` and a later call may resume the shutdown.
2209    /// 5. Sync required durable state (group-sync the shared WAL).
2210    /// 6. Stop workers — the Stage 1A core runs no background worker set.
2211    /// 7. Release the file lock (and the process open-registry entries).
2212    /// 8. Mark `Closed`.
2213    ///
2214    /// Repeated calls after `Closed` return `Ok(())`. Dropping one handle has
2215    /// no storage side effects; only this method (or the last `Arc` drop)
2216    /// closes storage.
2217    pub fn shutdown(self: &Arc<Self>, drain_deadline: std::time::Duration) -> Result<()> {
2218        self.ensure_owner_process()?;
2219        let initiated = self.lifecycle.begin_shutdown();
2220        if !initiated {
2221            match self.lifecycle.state() {
2222                crate::core::LifecycleState::Closed => return Ok(()),
2223                // A concurrent or timed-out shutdown left the core draining;
2224                // join it and drive the remaining steps.
2225                crate::core::LifecycleState::Draining => {}
2226                state => {
2227                    return Err(MongrelError::Conflict(format!(
2228                        "database core cannot shut down from lifecycle state {state}"
2229                    )))
2230                }
2231            }
2232        }
2233        if let Some(registration) = self.registry.get() {
2234            registration
2235                .manager
2236                .mark_closing(&registration.identity, self);
2237        }
2238        self.lifecycle.wait_drained(drain_deadline)?;
2239        let sync = self.shared_wal.lock().group_sync().map(|_| ());
2240        self.lifecycle.mark_closing();
2241        let lease = self._lock.lock().take();
2242        drop(lease);
2243        if let Some(registration) = self.registry.get() {
2244            registration
2245                .manager
2246                .entry_closed(&registration.identity, self);
2247        }
2248        self.lifecycle.mark_closed();
2249        sync
2250    }
2251}
2252
2253impl Database {
2254    pub fn open_metrics() -> DatabaseOpenMetrics {
2255        DatabaseOpenMetrics {
2256            lock_waits: DATABASE_OPEN_WAIT_COUNT.load(Ordering::Relaxed),
2257            failures: DATABASE_OPEN_FAILURE_COUNT.load(Ordering::Relaxed),
2258        }
2259    }
2260
2261    /// The storage core this owner references (spec §10.1, S1A-001).
2262    pub fn core(&self) -> Arc<DatabaseCore> {
2263        Arc::clone(&self.core)
2264    }
2265
2266    /// The identity bound to this owner (spec §10.1, S1A-001): a catalog user
2267    /// for credentialed opens, `Credentialless` otherwise.
2268    pub fn identity(&self) -> crate::handle::HandleIdentity {
2269        match self.principal.read().as_ref() {
2270            Some(principal) => crate::handle::HandleIdentity::CatalogUser {
2271                username: principal.username.clone(),
2272                user_id: principal.user_id,
2273                created_version: principal.created_epoch,
2274            },
2275            None => crate::handle::HandleIdentity::Credentialless,
2276        }
2277    }
2278
2279    /// The current lifecycle state of the underlying storage core (S1A-004).
2280    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2281        self.core.lifecycle_state()
2282    }
2283
2284    /// Admit one operation against the storage core (S1A-004). The returned
2285    /// RAII guard releases the operation slot on drop; new operations are
2286    /// rejected once the core leaves [`crate::core::LifecycleState::Open`].
2287    pub fn operation_guard(&self) -> Result<crate::core::OperationGuard> {
2288        self.core.operation_guard()
2289    }
2290
2291    /// Build an owner facade over an existing shared core. Used by
2292    /// [`crate::manager::DatabaseManager`] to back [`crate::handle::DatabaseHandle`]s;
2293    /// exclusive `Database::open*` constructors build their own core instead.
2294    pub(crate) fn from_core(
2295        core: Arc<DatabaseCore>,
2296        principal: Option<crate::auth::Principal>,
2297        shared: bool,
2298    ) -> Self {
2299        let require_auth = core.catalog.read().require_auth;
2300        Self {
2301            core,
2302            principal: RwLock::new(principal.clone()),
2303            auth_state: crate::auth_state::AuthState::new(require_auth, principal),
2304            shared,
2305        }
2306    }
2307
2308    /// Consume this owner and yield the shared core (keeps the core alive).
2309    pub(crate) fn into_core(self) -> Arc<DatabaseCore> {
2310        self.core
2311    }
2312
2313    /// Open an existing plaintext database as the one core backing shared
2314    /// handles (spec §10.1, S1A-002). Recovery, WAL opening, open-generation
2315    /// advancement, and table mounting all happen inside this call — exactly
2316    /// once per core.
2317    pub(crate) fn open_for_shared_core(root: impl AsRef<Path>) -> Result<Self> {
2318        Self::open_inner_with_lock_timeout(
2319            root,
2320            None,
2321            None,
2322            0,
2323            CoreResourceConfig::default(),
2324            OpenModeGate::SharedCore,
2325        )
2326    }
2327
2328    /// Explicitly close the final shared database owner.
2329    ///
2330    /// On a manager-issued facade (`shared`) this rejects: dropping one handle
2331    /// never closes storage while another handle exists, and shared cores are
2332    /// shut down explicitly via [`crate::handle::DatabaseHandle::shutdown`].
2333    pub fn shutdown(self: Arc<Self>) -> Result<()> {
2334        if self.shared {
2335            return Err(MongrelError::Conflict(
2336                "shared-core facades do not own storage; use DatabaseHandle::shutdown".into(),
2337            ));
2338        }
2339        match Arc::try_unwrap(self) {
2340            Ok(database) => {
2341                database.ensure_owner_process()?;
2342                // Drain + close the exclusively-owned core (S1A-004 steps:
2343                // reject new operations, wait for the drain, sync durable
2344                // state, release the file lock, mark Closed), then drop it.
2345                database.core.shutdown(std::time::Duration::from_secs(30))?;
2346                drop(database);
2347                Ok(())
2348            }
2349            Err(database) => Err(MongrelError::DatabaseBusy {
2350                strong_handles: Arc::strong_count(&database),
2351            }),
2352        }
2353    }
2354
2355    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
2356        if let Ok(canonical) = root.canonicalize() {
2357            let lock_dir = canonical.parent().ok_or_else(|| {
2358                std::io::Error::new(
2359                    std::io::ErrorKind::InvalidInput,
2360                    "database root must have a parent directory",
2361                )
2362            })?;
2363            return Ok((canonical.clone(), lock_dir.to_path_buf()));
2364        }
2365
2366        let absolute = if root.is_absolute() {
2367            root.to_path_buf()
2368        } else {
2369            std::env::current_dir()?.join(root)
2370        };
2371        let mut cursor = absolute.as_path();
2372        let mut suffix = Vec::new();
2373        while !cursor.exists() {
2374            let name = cursor.file_name().ok_or_else(|| {
2375                std::io::Error::new(
2376                    std::io::ErrorKind::NotFound,
2377                    format!("no existing ancestor for database root {}", root.display()),
2378                )
2379            })?;
2380            suffix.push(name.to_os_string());
2381            cursor = cursor.parent().ok_or_else(|| {
2382                std::io::Error::new(
2383                    std::io::ErrorKind::NotFound,
2384                    format!("no existing ancestor for database root {}", root.display()),
2385                )
2386            })?;
2387        }
2388        let lock_dir = cursor.canonicalize()?;
2389        let mut canonical = lock_dir.clone();
2390        for component in suffix.iter().rev() {
2391            canonical.push(component);
2392        }
2393        Ok((canonical, lock_dir))
2394    }
2395
2396    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<ExclusiveDatabaseLease> {
2397        use std::hash::{Hash, Hasher};
2398
2399        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
2400        let reservation =
2401            OpenReservation::acquire(DatabaseOpenKey::IntendedPath(canonical_path.clone()), root)?;
2402
2403        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2404        canonical_path.hash(&mut hasher);
2405        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
2406        let file = std::fs::OpenOptions::new()
2407            .create(true)
2408            .truncate(false)
2409            .write(true)
2410            .open(lock_path)?;
2411        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2412            return Err(MongrelError::DatabaseLocked {
2413                path: root.to_path_buf(),
2414                message: error.to_string(),
2415            });
2416        }
2417        Ok(reservation.into_lease(file, canonical_path))
2418    }
2419
2420    fn acquire_legacy_database_lock(
2421        lock: &mut ExclusiveDatabaseLease,
2422        root: &Path,
2423        timeout_ms: u32,
2424    ) -> Result<()> {
2425        let durable_root = lock
2426            .durable_root
2427            .as_ref()
2428            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
2429        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
2430        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2431            return Err(MongrelError::DatabaseLocked {
2432                path: root.to_path_buf(),
2433                message: error.to_string(),
2434            });
2435        }
2436        lock.legacy_file = Some(file);
2437        Ok(())
2438    }
2439
2440    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
2441        if path.exists() {
2442            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
2443        }
2444        let mut ancestor = path;
2445        while !ancestor.exists() {
2446            ancestor = ancestor.parent().ok_or_else(|| {
2447                MongrelError::NotFound(format!(
2448                    "no existing ancestor for database root {}",
2449                    path.display()
2450                ))
2451            })?;
2452        }
2453        let relative = path.strip_prefix(ancestor).map_err(|error| {
2454            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
2455        })?;
2456        crate::durable_file::DurableRoot::open(ancestor)?
2457            .create_directory_all_pinned(relative)
2458            .map_err(Into::into)
2459    }
2460
2461    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2462        let requested_root = root.as_ref();
2463        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
2464        let root = lock.canonical_path.clone();
2465        Self::reject_existing_database(&root)?;
2466        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
2467        if durable_root.canonical_path() != lock.canonical_path {
2468            return Err(MongrelError::Conflict(
2469                "database root changed while it was being created".into(),
2470            ));
2471        }
2472        lock.claim_root_identity(&durable_root)?;
2473        durable_root.create_directory_all(META_DIR)?;
2474        lock.durable_root = Some(durable_root);
2475        let io_root = lock
2476            .durable_root
2477            .as_ref()
2478            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2479            .io_path()?;
2480        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
2481        Self::reject_existing_database(&io_root)?;
2482        Ok((io_root, lock))
2483    }
2484
2485    fn begin_open(
2486        root: impl AsRef<Path>,
2487        lock_timeout_ms: u32,
2488    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2489        let root = root.as_ref();
2490        let canonical_root = root.canonicalize().map_err(|error| {
2491            if error.kind() == std::io::ErrorKind::NotFound {
2492                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
2493            } else {
2494                error.into()
2495            }
2496        })?;
2497        let durable_root = crate::durable_file::DurableRoot::open(&canonical_root)?;
2498        Self::begin_open_durable(durable_root, lock_timeout_ms)
2499    }
2500
2501    fn begin_open_durable(
2502        durable_root: crate::durable_file::DurableRoot,
2503        lock_timeout_ms: u32,
2504    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2505        let io_root = durable_root.io_path()?;
2506        let current_root = io_root.canonicalize()?;
2507        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
2508        lock.claim_root_identity(&durable_root)?;
2509        lock.durable_root = Some(Arc::new(durable_root));
2510        let io_root = lock
2511            .durable_root
2512            .as_ref()
2513            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2514            .io_path()?;
2515        if lock
2516            .durable_root
2517            .as_ref()
2518            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2519            .open_directory(META_DIR)
2520            .is_err()
2521        {
2522            return Err(MongrelError::NotFound(format!(
2523                "no database metadata found at {:?}",
2524                current_root
2525            )));
2526        }
2527        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
2528        Ok((io_root, lock))
2529    }
2530
2531    /// Create a fresh plaintext database at `root`.
2532    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
2533        let (root, lock) = Self::begin_create(root)?;
2534        Self::create_inner(
2535            root,
2536            None,
2537            lock,
2538            crate::storage_mode::StorageMode::Standalone,
2539        )
2540    }
2541
2542    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
2543    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
2544    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2545        let (root, lock) = Self::begin_create(root)?;
2546        let salt = crate::encryption::random_salt()?;
2547        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2548        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2549        Self::create_inner(
2550            root,
2551            Some(kek),
2552            lock,
2553            crate::storage_mode::StorageMode::Standalone,
2554        )
2555    }
2556
2557    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
2558    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
2559    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2560        let (root, lock) = Self::begin_create(root)?;
2561        let salt = crate::encryption::random_salt()?;
2562        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2563        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2564        Self::create_inner(
2565            root,
2566            Some(kek),
2567            lock,
2568            crate::storage_mode::StorageMode::Standalone,
2569        )
2570    }
2571
2572    /// Create a fresh encrypted database whose random root key is wrapped by
2573    /// an external KMS provider.
2574    pub fn create_with_kms(
2575        root: impl AsRef<Path>,
2576        provider: &dyn crate::security_hardening::KeyManagementProvider,
2577        kms_key_id: &str,
2578    ) -> Result<Self> {
2579        let (root, lock) = Self::begin_create(root)?;
2580        let kek = create_kms_kek(&root, provider, kms_key_id)?;
2581        Self::create_inner(
2582            root,
2583            Some(kek),
2584            lock,
2585            crate::storage_mode::StorageMode::Standalone,
2586        )
2587    }
2588
2589    /// Create a fresh plaintext database owned by the cluster node runtime
2590    /// (spec section 5.3): the durable marker records
2591    /// [`crate::storage_mode::StorageMode::ClusterReplica`], so normal
2592    /// `Database::open*` paths reject the directory and only
2593    /// [`Database::open_cluster_replica`] (or the read-only offline validator)
2594    /// can open it afterwards. The returned core rejects user writes: every
2595    /// mutation arrives through the replicated apply path (Stage 2E).
2596    pub fn create_cluster_replica(
2597        root: impl AsRef<Path>,
2598        cluster_id: mongreldb_types::ids::ClusterId,
2599        node_id: mongreldb_types::ids::NodeId,
2600        database_id: mongreldb_types::ids::DatabaseId,
2601    ) -> Result<Self> {
2602        let (root, lock) = Self::begin_create(root)?;
2603        Self::create_inner(
2604            root,
2605            None,
2606            lock,
2607            crate::storage_mode::StorageMode::ClusterReplica {
2608                cluster_id,
2609                node_id,
2610                database_id,
2611            },
2612        )
2613    }
2614
2615    /// Open a cluster-replica database as the cluster node runtime (spec
2616    /// section 5.3). Fails closed unless the durable marker exists and exactly
2617    /// matches `expected` (a `ClusterReplica` identity): opening a replica of
2618    /// the wrong cluster or database would corrupt both. The opened core
2619    /// rejects user writes with [`MongrelError::ReadOnlyReplica`]; mutations
2620    /// arrive through the replicated apply path (Stage 2E).
2621    pub fn open_cluster_replica(
2622        root: impl AsRef<Path>,
2623        expected: &crate::storage_mode::StorageMode,
2624    ) -> Result<Self> {
2625        let Some((cluster_id, node_id, database_id)) = expected.cluster_identity() else {
2626            return Err(MongrelError::InvalidArgument(format!(
2627                "open_cluster_replica requires a ClusterReplica identity, got {expected:?}"
2628            )));
2629        };
2630        let (root, lock) = Self::begin_open(root, 0)?;
2631        Self::open_inner_locked(
2632            root,
2633            None,
2634            lock,
2635            CoreResourceConfig::default(),
2636            OpenModeGate::ClusterRuntime {
2637                cluster_id,
2638                node_id,
2639                database_id,
2640            },
2641        )
2642    }
2643
2644    fn create_inner(
2645        root: PathBuf,
2646        kek: Option<Arc<crate::encryption::Kek>>,
2647        lock: ExclusiveDatabaseLease,
2648        storage_mode: crate::storage_mode::StorageMode,
2649    ) -> Result<Self> {
2650        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2651        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2652        let cat = Catalog::empty();
2653        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2654        Self::finish_open(
2655            root,
2656            cat,
2657            kek,
2658            meta_dek,
2659            false,
2660            None,
2661            None,
2662            None,
2663            lock,
2664            CoreResourceConfig::default(),
2665            OpenModeGate::Create(storage_mode),
2666        )
2667    }
2668
2669    /// Open an existing plaintext database.
2670    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
2671        Self::open_inner(root, None, None)
2672    }
2673
2674    /// Open an existing encrypted database with a passphrase.
2675    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2676        let (root, lock) = Self::begin_open(root, 0)?;
2677        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2678            MongrelError::Other("database root descriptor was not pinned".into())
2679        })?)?;
2680        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2681        Self::open_inner_locked(
2682            root,
2683            Some(kek),
2684            lock,
2685            CoreResourceConfig::default(),
2686            OpenModeGate::Normal,
2687        )
2688    }
2689
2690    /// Open an existing encrypted database with a configurable cross-process
2691    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
2692    pub fn open_encrypted_with_options(
2693        root: impl AsRef<Path>,
2694        passphrase: &str,
2695        options: OpenOptions,
2696    ) -> Result<Self> {
2697        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2698        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2699            MongrelError::Other("database root descriptor was not pinned".into())
2700        })?)?;
2701        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2702        let gate = if options.offline_validation {
2703            OpenModeGate::OfflineValidation
2704        } else {
2705            OpenModeGate::Normal
2706        };
2707        Self::open_inner_locked(
2708            root,
2709            Some(kek),
2710            lock,
2711            CoreResourceConfig::from_options(&options)?,
2712            gate,
2713        )
2714    }
2715
2716    /// Open an existing encrypted database using a raw high-entropy key.
2717    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2718        let (root, lock) = Self::begin_open(root, 0)?;
2719        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2720            MongrelError::Other("database root descriptor was not pinned".into())
2721        })?)?;
2722        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2723        Self::open_inner_locked(
2724            root,
2725            Some(kek),
2726            lock,
2727            CoreResourceConfig::default(),
2728            OpenModeGate::Normal,
2729        )
2730    }
2731
2732    /// Open a KMS-encrypted database. Provider identity must match the durable
2733    /// envelope and unwrapping fails closed.
2734    pub fn open_with_kms(
2735        root: impl AsRef<Path>,
2736        provider: &dyn crate::security_hardening::KeyManagementProvider,
2737    ) -> Result<Self> {
2738        Self::open_with_kms_and_options(root, provider, OpenOptions::default())
2739    }
2740
2741    /// Open a KMS-encrypted database with non-default open options.
2742    pub fn open_with_kms_and_options(
2743        root: impl AsRef<Path>,
2744        provider: &dyn crate::security_hardening::KeyManagementProvider,
2745        options: OpenOptions,
2746    ) -> Result<Self> {
2747        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2748        let kek = open_kms_kek(
2749            lock.durable_root.as_deref().ok_or_else(|| {
2750                MongrelError::Other("database root descriptor was not pinned".into())
2751            })?,
2752            provider,
2753        )?;
2754        let gate = if options.offline_validation {
2755            OpenModeGate::OfflineValidation
2756        } else {
2757            OpenModeGate::Normal
2758        };
2759        Self::open_inner_locked(
2760            root,
2761            Some(kek),
2762            lock,
2763            CoreResourceConfig::from_options(&options)?,
2764            gate,
2765        )
2766    }
2767
2768    /// Rewrap the database root key under another KMS key without stopping
2769    /// reads or writes. Data files stay encrypted under the same random root
2770    /// key; only its external envelope changes.
2771    pub fn rotate_kms_key(
2772        &self,
2773        provider: &dyn crate::security_hardening::KeyManagementProvider,
2774        new_kms_key_id: &str,
2775    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2776        let _rotation_guard = self.core.kms_rotation_lock.lock();
2777        if new_kms_key_id.is_empty() {
2778            return Err(MongrelError::InvalidArgument(
2779                "KMS key id must not be empty".into(),
2780            ));
2781        }
2782        let durable_root = Arc::clone(&self.core.durable_root);
2783        let root = durable_root.io_path()?;
2784        let envelope = read_kms_key_envelope(&durable_root)?;
2785        if envelope.provider_id != provider.provider_id() {
2786            return Err(MongrelError::Encryption(format!(
2787                "KMS provider mismatch: database requires {:?}, got {:?}",
2788                envelope.provider_id,
2789                provider.provider_id()
2790            )));
2791        }
2792        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2793        if let Some(record) = journal
2794            .load()
2795            .map_err(|error| MongrelError::Encryption(error.to_string()))?
2796            .filter(|record| record.phase != crate::security_hardening::KeyRotationPhase::Succeeded)
2797        {
2798            if record.to_key_id != new_kms_key_id {
2799                return Err(MongrelError::Conflict(format!(
2800                    "KMS rotation to {:?} is already in progress",
2801                    record.to_key_id
2802                )));
2803            }
2804            return self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record);
2805        }
2806        let now_micros = std::time::SystemTime::now()
2807            .duration_since(std::time::UNIX_EPOCH)
2808            .unwrap_or_default()
2809            .as_micros() as u64;
2810        let mut record = crate::security_hardening::KeyRotationRecord::new(
2811            envelope.wrapped_key.key_version.clone(),
2812            String::new(),
2813            now_micros,
2814        );
2815        record.from_key_id = envelope.wrapped_key.kms_key_id.clone();
2816        record.to_key_id = new_kms_key_id.to_owned();
2817        persist_key_rotation(&journal, &record)?;
2818        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
2819    }
2820
2821    /// Retry a rotation that durably entered `Failed`.
2822    pub fn retry_kms_key_rotation(
2823        &self,
2824        provider: &dyn crate::security_hardening::KeyManagementProvider,
2825    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2826        let _rotation_guard = self.core.kms_rotation_lock.lock();
2827        let durable_root = Arc::clone(&self.core.durable_root);
2828        let root = durable_root.io_path()?;
2829        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2830        let mut record = journal
2831            .load()
2832            .map_err(|error| MongrelError::Encryption(error.to_string()))?
2833            .ok_or_else(|| MongrelError::NotFound("KMS rotation journal".into()))?;
2834        if record.phase != crate::security_hardening::KeyRotationPhase::Failed {
2835            return Err(MongrelError::Conflict(
2836                "KMS rotation is not in Failed state".into(),
2837            ));
2838        }
2839        record.retry();
2840        persist_key_rotation(&journal, &record)?;
2841        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
2842    }
2843
2844    fn run_kms_key_rotation(
2845        &self,
2846        provider: &dyn crate::security_hardening::KeyManagementProvider,
2847        durable_root: &crate::durable_file::DurableRoot,
2848        root: &Path,
2849        journal: &crate::security_hardening::KeyRotationJournal,
2850        mut record: crate::security_hardening::KeyRotationRecord,
2851    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2852        use crate::security_hardening::{KeyRotationPhase, KmsDatabaseKeyEnvelope};
2853        loop {
2854            match record.phase {
2855                KeyRotationPhase::Pending => advance_key_rotation(journal, &mut record)?,
2856                KeyRotationPhase::WrappingNewKey => {
2857                    if record.staged_wrapped_key.is_none() {
2858                        let current = read_kms_key_envelope(durable_root)?;
2859                        let wrapped =
2860                            match provider.rewrap_key(&current.wrapped_key, &record.to_key_id) {
2861                                Ok(wrapped) => wrapped,
2862                                Err(error) => {
2863                                    return fail_key_rotation(journal, &mut record, error);
2864                                }
2865                            };
2866                        record.to_version = wrapped.key_version.clone();
2867                        record.staged_wrapped_key = Some(wrapped);
2868                        persist_key_rotation(journal, &record)?;
2869                    }
2870                    advance_key_rotation(journal, &mut record)?;
2871                }
2872                KeyRotationPhase::DualRead => {
2873                    let old_envelope = read_kms_key_envelope(durable_root)?;
2874                    let new_envelope = KmsDatabaseKeyEnvelope {
2875                        provider_id: provider.provider_id().to_owned(),
2876                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
2877                            MongrelError::Encryption(
2878                                "KMS rotation has no staged wrapped key".into(),
2879                            )
2880                        })?,
2881                    };
2882                    let old_raw = match unwrap_kms_database_key(provider, &old_envelope) {
2883                        Ok(key) => key,
2884                        Err(error) => {
2885                            return fail_key_rotation(journal, &mut record, error);
2886                        }
2887                    };
2888                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
2889                        Ok(key) => key,
2890                        Err(error) => {
2891                            return fail_key_rotation(journal, &mut record, error);
2892                        }
2893                    };
2894                    if !bool::from(old_raw.as_slice().ct_eq(new_raw.as_slice())) {
2895                        return fail_key_rotation(
2896                            journal,
2897                            &mut record,
2898                            "KMS rewrap changed the database root key",
2899                        );
2900                    }
2901                    advance_key_rotation(journal, &mut record)?;
2902                }
2903                KeyRotationPhase::Reencrypting => {
2904                    // Envelope rotation keeps the database root key stable, so
2905                    // pages, WAL records, and active readers need no rewrite.
2906                    advance_key_rotation(journal, &mut record)?;
2907                }
2908                KeyRotationPhase::Validating => {
2909                    let new_envelope = KmsDatabaseKeyEnvelope {
2910                        provider_id: provider.provider_id().to_owned(),
2911                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
2912                            MongrelError::Encryption(
2913                                "KMS rotation has no staged wrapped key".into(),
2914                            )
2915                        })?,
2916                    };
2917                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
2918                        Ok(key) => key,
2919                        Err(error) => {
2920                            return fail_key_rotation(journal, &mut record, error);
2921                        }
2922                    };
2923                    let salt = read_encryption_salt(durable_root)?;
2924                    let candidate = crate::encryption::Kek::from_raw_key(new_raw.as_ref(), &salt)?;
2925                    let candidate_meta = candidate.derive_meta_key();
2926                    let Some(active_meta) = self.core.meta_dek.as_ref() else {
2927                        return fail_key_rotation(
2928                            journal,
2929                            &mut record,
2930                            "KMS metadata exists on a plaintext database",
2931                        );
2932                    };
2933                    if !bool::from(candidate_meta.as_ref().ct_eq(active_meta.as_slice())) {
2934                        return fail_key_rotation(
2935                            journal,
2936                            &mut record,
2937                            "KMS rewrap validation failed",
2938                        );
2939                    }
2940                    advance_key_rotation(journal, &mut record)?;
2941                }
2942                KeyRotationPhase::Published => {
2943                    let envelope = KmsDatabaseKeyEnvelope {
2944                        provider_id: provider.provider_id().to_owned(),
2945                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
2946                            MongrelError::Encryption(
2947                                "KMS rotation has no staged wrapped key".into(),
2948                            )
2949                        })?,
2950                    };
2951                    write_kms_key_envelope(root, &envelope)?;
2952                    advance_key_rotation(journal, &mut record)?;
2953                }
2954                KeyRotationPhase::RetiringOldKey => {
2955                    advance_key_rotation(journal, &mut record)?;
2956                }
2957                KeyRotationPhase::Succeeded => return Ok(record),
2958                KeyRotationPhase::Failed => {
2959                    return Err(MongrelError::Encryption(
2960                        "failed KMS rotation must be explicitly retried".into(),
2961                    ));
2962                }
2963            }
2964        }
2965    }
2966
2967    /// Open an existing plaintext database that has `require_auth = true`,
2968    /// verifying the supplied credentials up front and caching the resolved
2969    /// [`Principal`] on the returned handle. Every subsequent operation will
2970    /// be checked against that principal.
2971    ///
2972    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
2973    /// `require_auth` enabled — callers must pick the matching constructor for
2974    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
2975    /// bad username/password.
2976    ///
2977    /// See `docs/15-credential-enforcement.md`.
2978    pub fn open_with_credentials(
2979        root: impl AsRef<Path>,
2980        username: &str,
2981        password: &str,
2982    ) -> Result<Self> {
2983        Self::open_inner_with_credentials(root, None, username, password)
2984    }
2985
2986    /// Open with credentials and a configurable cross-process lock timeout.
2987    /// Mirrors [`open_with_options`](Self::open_with_options) for the
2988    /// credentialed path.
2989    pub fn open_with_credentials_and_options(
2990        root: impl AsRef<Path>,
2991        username: &str,
2992        password: &str,
2993        options: OpenOptions,
2994    ) -> Result<Self> {
2995        let gate = if options.offline_validation {
2996            OpenModeGate::OfflineValidation
2997        } else {
2998            OpenModeGate::Normal
2999        };
3000        Self::open_inner_with_credentials_and_lock_timeout(
3001            root,
3002            None,
3003            username,
3004            password,
3005            options.lock_timeout_ms,
3006            CoreResourceConfig::from_options(&options)?,
3007            gate,
3008        )
3009    }
3010
3011    /// Open an existing encrypted database that has `require_auth = true`,
3012    /// combining the encryption passphrase flow with credential verification.
3013    pub fn open_encrypted_with_credentials(
3014        root: impl AsRef<Path>,
3015        passphrase: &str,
3016        username: &str,
3017        password: &str,
3018    ) -> Result<Self> {
3019        let (root, lock) = Self::begin_open(root, 0)?;
3020        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3021            MongrelError::Other("database root descriptor was not pinned".into())
3022        })?)?;
3023        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3024        Self::open_inner_with_credentials_locked(
3025            root,
3026            Some(kek),
3027            username,
3028            password,
3029            lock,
3030            CoreResourceConfig::default(),
3031            OpenModeGate::Normal,
3032        )
3033    }
3034
3035    /// Open an encrypted + credentialed database with a configurable
3036    /// cross-process lock timeout. Mirrors
3037    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
3038    pub fn open_encrypted_with_credentials_and_options(
3039        root: impl AsRef<Path>,
3040        passphrase: &str,
3041        username: &str,
3042        password: &str,
3043        options: OpenOptions,
3044    ) -> Result<Self> {
3045        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
3046        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3047            MongrelError::Other("database root descriptor was not pinned".into())
3048        })?)?;
3049        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3050        let gate = if options.offline_validation {
3051            OpenModeGate::OfflineValidation
3052        } else {
3053            OpenModeGate::Normal
3054        };
3055        Self::open_inner_with_credentials_locked(
3056            root,
3057            Some(kek),
3058            username,
3059            password,
3060            lock,
3061            CoreResourceConfig::from_options(&options)?,
3062            gate,
3063        )
3064    }
3065
3066    /// Open a KMS-encrypted database and authenticate the returned handle.
3067    pub fn open_with_kms_and_credentials(
3068        root: impl AsRef<Path>,
3069        provider: &dyn crate::security_hardening::KeyManagementProvider,
3070        username: &str,
3071        password: &str,
3072    ) -> Result<Self> {
3073        let (root, lock) = Self::begin_open(root, 0)?;
3074        let kek = open_kms_kek(
3075            lock.durable_root.as_deref().ok_or_else(|| {
3076                MongrelError::Other("database root descriptor was not pinned".into())
3077            })?,
3078            provider,
3079        )?;
3080        Self::open_inner_with_credentials_locked(
3081            root,
3082            Some(kek),
3083            username,
3084            password,
3085            lock,
3086            CoreResourceConfig::default(),
3087            OpenModeGate::Normal,
3088        )
3089    }
3090
3091    /// Open an existing database with non-default [`OpenOptions`].
3092    ///
3093    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
3094    /// rather than the fail-fast default. The other open constructors keep
3095    /// their previous defaults; use their `*_with_options` variants when they
3096    /// need the same timeout behavior.
3097    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
3098        // No encryption, no auth; encrypted and credentialed paths have their
3099        // own `*_with_options` constructors.
3100        let gate = if options.offline_validation {
3101            OpenModeGate::OfflineValidation
3102        } else {
3103            OpenModeGate::Normal
3104        };
3105        Self::open_inner_with_lock_timeout(
3106            root,
3107            None,
3108            None,
3109            options.lock_timeout_ms,
3110            CoreResourceConfig::from_options(&options)?,
3111            gate,
3112        )
3113    }
3114
3115    fn open_inner_with_lock_timeout(
3116        root: impl AsRef<Path>,
3117        kek: Option<Arc<crate::encryption::Kek>>,
3118        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3119        lock_timeout_ms: u32,
3120        resources: CoreResourceConfig,
3121        mode_gate: OpenModeGate,
3122    ) -> Result<Self> {
3123        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3124        Self::open_inner_locked(root, kek, lock, resources, mode_gate)
3125    }
3126
3127    fn open_inner_locked(
3128        root: PathBuf,
3129        kek: Option<Arc<crate::encryption::Kek>>,
3130        lock: ExclusiveDatabaseLease,
3131        resources: CoreResourceConfig,
3132        mode_gate: OpenModeGate,
3133    ) -> Result<Self> {
3134        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3135        let mut cat = catalog::read_durable(
3136            lock.durable_root.as_deref().ok_or_else(|| {
3137                MongrelError::Other("database root descriptor was not pinned".into())
3138            })?,
3139            meta_dek.as_ref(),
3140        )?
3141        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3142        let recovery_checkpoint = cat.clone();
3143
3144        // CATALOG is only a checkpoint. Authentication must use the
3145        // authoritative catalog after committed WAL DDL/security replay.
3146        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3147        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3148            lock.durable_root.as_deref().ok_or_else(|| {
3149                MongrelError::Other("database root descriptor was not pinned".into())
3150            })?,
3151            wal_dek.as_ref(),
3152        )?;
3153        recover_ddl_from_records(
3154            &root,
3155            Some(lock.durable_root.as_deref().ok_or_else(|| {
3156                MongrelError::Other("database root descriptor was not pinned".into())
3157            })?),
3158            &mut cat,
3159            meta_dek.as_ref(),
3160            false,
3161            None,
3162            &recovery_records,
3163        )?;
3164        Self::finish_open(
3165            root,
3166            cat,
3167            kek,
3168            meta_dek,
3169            true,
3170            Some(recovery_checkpoint),
3171            Some(recovery_records),
3172            None,
3173            lock,
3174            resources,
3175            mode_gate,
3176        )
3177    }
3178
3179    /// Shared credentialed-open inner: read the catalog, verify the database
3180    /// requires auth, verify the password, resolve the principal, and pass
3181    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
3182    /// problem where `finish_open`'s fail-closed check (`require_auth &&
3183    /// principal.is_none()`) would fire before a post-open `authenticate()`
3184    /// could supply the principal.
3185    fn open_inner_with_credentials(
3186        root: impl AsRef<Path>,
3187        kek: Option<Arc<crate::encryption::Kek>>,
3188        username: &str,
3189        password: &str,
3190    ) -> Result<Self> {
3191        Self::open_inner_with_credentials_and_lock_timeout(
3192            root,
3193            kek,
3194            username,
3195            password,
3196            0,
3197            CoreResourceConfig::default(),
3198            OpenModeGate::Normal,
3199        )
3200    }
3201
3202    /// Credentialed-open with an explicit cross-process lock timeout. The
3203    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
3204    /// historical fail-fast behavior via the wrapper above.
3205    #[allow(clippy::too_many_arguments)]
3206    fn open_inner_with_credentials_and_lock_timeout(
3207        root: impl AsRef<Path>,
3208        kek: Option<Arc<crate::encryption::Kek>>,
3209        username: &str,
3210        password: &str,
3211        lock_timeout_ms: u32,
3212        resources: CoreResourceConfig,
3213        mode_gate: OpenModeGate,
3214    ) -> Result<Self> {
3215        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3216        Self::open_inner_with_credentials_locked(
3217            root, kek, username, password, lock, resources, mode_gate,
3218        )
3219    }
3220
3221    #[allow(clippy::too_many_arguments)]
3222    fn open_inner_with_credentials_locked(
3223        root: PathBuf,
3224        kek: Option<Arc<crate::encryption::Kek>>,
3225        username: &str,
3226        password: &str,
3227        lock: ExclusiveDatabaseLease,
3228        resources: CoreResourceConfig,
3229        mode_gate: OpenModeGate,
3230    ) -> Result<Self> {
3231        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3232        let mut cat = catalog::read_durable(
3233            lock.durable_root.as_deref().ok_or_else(|| {
3234                MongrelError::Other("database root descriptor was not pinned".into())
3235            })?,
3236            meta_dek.as_ref(),
3237        )?
3238        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3239        let recovery_checkpoint = cat.clone();
3240
3241        // Never verify against a stale checkpoint. A committed password,
3242        // user, role, or auth-mode change in WAL is authoritative.
3243        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3244        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3245            lock.durable_root.as_deref().ok_or_else(|| {
3246                MongrelError::Other("database root descriptor was not pinned".into())
3247            })?,
3248            wal_dek.as_ref(),
3249        )?;
3250        recover_ddl_from_records(
3251            &root,
3252            Some(lock.durable_root.as_deref().ok_or_else(|| {
3253                MongrelError::Other("database root descriptor was not pinned".into())
3254            })?),
3255            &mut cat,
3256            meta_dek.as_ref(),
3257            false,
3258            None,
3259            &recovery_records,
3260        )?;
3261
3262        // Fail early if the database is not in require_auth mode — the caller
3263        // picked the wrong constructor.
3264        if !cat.require_auth {
3265            return Err(MongrelError::AuthNotRequired);
3266        }
3267
3268        // Verify credentials against the on-disk catalog before constructing
3269        // the full Database handle. This reads users/hashes directly from the
3270        // loaded catalog rather than going through the Database::verify_user
3271        // method (which requires a constructed Database).
3272        let user = cat
3273            .users
3274            .iter()
3275            .find(|u| u.username == username)
3276            .filter(|u| !u.password_hash.is_empty())
3277            .ok_or_else(|| MongrelError::InvalidCredentials {
3278                username: username.to_string(),
3279            })?;
3280        let password_ok = crate::auth::verify_password(password, &user.password_hash)
3281            .map_err(MongrelError::Other)?;
3282        if !password_ok {
3283            return Err(MongrelError::InvalidCredentials {
3284                username: username.to_string(),
3285            });
3286        }
3287
3288        // Resolve the principal from the catalog (roles + permissions).
3289        let principal =
3290            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
3291                MongrelError::InvalidCredentials {
3292                    username: username.to_string(),
3293                }
3294            })?;
3295
3296        Self::finish_open(
3297            root,
3298            cat,
3299            kek,
3300            meta_dek,
3301            true,
3302            Some(recovery_checkpoint),
3303            Some(recovery_records),
3304            Some(principal),
3305            lock,
3306            resources,
3307            mode_gate,
3308        )
3309    }
3310
3311    /// Create a fresh plaintext database with `require_auth = true` and a
3312    /// single admin user. The returned handle is already authenticated as
3313    /// that admin — every subsequent operation is checked against the admin
3314    /// principal (which bypasses all permission checks via `is_admin`).
3315    ///
3316    /// This is the bootstrap path: there is no window where the database
3317    /// requires auth but has no users.
3318    ///
3319    /// See `docs/15-credential-enforcement.md`.
3320    pub fn create_with_credentials(
3321        root: impl AsRef<Path>,
3322        admin_username: &str,
3323        admin_password: &str,
3324    ) -> Result<Self> {
3325        let (root, lock) = Self::begin_create(root)?;
3326        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
3327    }
3328
3329    /// Create a fresh encrypted database with `require_auth = true` and a
3330    /// single admin user. Composes encryption-at-rest with credential
3331    /// enforcement.
3332    pub fn create_encrypted_with_credentials(
3333        root: impl AsRef<Path>,
3334        passphrase: &str,
3335        admin_username: &str,
3336        admin_password: &str,
3337    ) -> Result<Self> {
3338        let (root, lock) = Self::begin_create(root)?;
3339        let salt = crate::encryption::random_salt()?;
3340        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
3341        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3342        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3343    }
3344
3345    /// Create a KMS-encrypted database with an authenticated admin handle.
3346    pub fn create_with_kms_and_credentials(
3347        root: impl AsRef<Path>,
3348        provider: &dyn crate::security_hardening::KeyManagementProvider,
3349        kms_key_id: &str,
3350        admin_username: &str,
3351        admin_password: &str,
3352    ) -> Result<Self> {
3353        let (root, lock) = Self::begin_create(root)?;
3354        let kek = create_kms_kek(&root, provider, kms_key_id)?;
3355        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3356    }
3357
3358    fn create_inner_with_credentials(
3359        root: PathBuf,
3360        kek: Option<Arc<crate::encryption::Kek>>,
3361        admin_username: &str,
3362        admin_password: &str,
3363        lock: ExclusiveDatabaseLease,
3364    ) -> Result<Self> {
3365        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
3366        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3367
3368        // Build the initial catalog with require_auth = true and one admin user.
3369        let password_hash =
3370            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
3371        let scram_sha_256 =
3372            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
3373        let mysql_caching_sha2 =
3374            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
3375        let mut cat = Catalog::empty();
3376        cat.require_auth = true;
3377        cat.next_user_id = 2;
3378        cat.users.push(crate::auth::UserEntry {
3379            id: 1,
3380            username: admin_username.to_string(),
3381            password_hash,
3382            scram_sha_256: Some(scram_sha_256),
3383            mysql_caching_sha2: Some(mysql_caching_sha2),
3384            roles: Vec::new(),
3385            is_admin: true,
3386            created_epoch: 0,
3387        });
3388        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3389
3390        // The handle is constructed already authenticated as the admin user
3391        // it just created — no separate verify step needed.
3392        let admin_principal = crate::auth::Principal {
3393            user_id: 1,
3394            created_epoch: 0,
3395            username: admin_username.to_string(),
3396            is_admin: true,
3397            roles: Vec::new(),
3398            permissions: Vec::new(),
3399        };
3400        Self::finish_open(
3401            root,
3402            cat,
3403            kek,
3404            meta_dek,
3405            false,
3406            None,
3407            None,
3408            Some(admin_principal),
3409            lock,
3410            CoreResourceConfig::default(),
3411            OpenModeGate::Create(crate::storage_mode::StorageMode::Standalone),
3412        )
3413    }
3414
3415    fn reject_existing_database(root: &Path) -> Result<()> {
3416        // Refuse to overwrite an existing database. If CATALOG exists, the
3417        // directory already contains a real database; replacing it destroys data.
3418        if root.join(catalog::CATALOG_FILENAME).exists() {
3419            return Err(MongrelError::InvalidArgument(format!(
3420                "database already exists at {}; use Database::open() to open it, \
3421                 or remove the directory first",
3422                root.display()
3423            )));
3424        }
3425        Ok(())
3426    }
3427
3428    fn open_inner(
3429        root: impl AsRef<Path>,
3430        kek: Option<Arc<crate::encryption::Kek>>,
3431        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3432    ) -> Result<Self> {
3433        Self::open_inner_with_lock_timeout(
3434            root,
3435            kek,
3436            None,
3437            0,
3438            CoreResourceConfig::default(),
3439            OpenModeGate::Normal,
3440        )
3441    }
3442
3443    /// Open an existing plaintext database through the special
3444    /// offline-validation API (spec section 5.3): any storage mode, read-only.
3445    pub(crate) fn open_offline_validation(root: impl AsRef<Path>) -> Result<Self> {
3446        Self::open_inner_with_lock_timeout(
3447            root,
3448            None,
3449            None,
3450            0,
3451            CoreResourceConfig::default(),
3452            OpenModeGate::OfflineValidation,
3453        )
3454    }
3455
3456    /// Internal recovery open for a staging directory explicitly marked as a
3457    /// read-only replica. It bypasses user authentication only so PITR can
3458    /// replay auth-mode and password transitions; it is not public API.
3459    pub(crate) fn open_replica_recovery_durable(
3460        root: &crate::durable_file::DurableRoot,
3461    ) -> Result<Self> {
3462        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3463        Self::open_replica_recovery_inner(root, None, lock)
3464    }
3465
3466    pub(crate) fn open_encrypted_replica_recovery_durable(
3467        root: &crate::durable_file::DurableRoot,
3468        passphrase: &str,
3469    ) -> Result<Self> {
3470        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3471        let salt = read_encryption_salt(root)?;
3472        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3473        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
3474    }
3475
3476    fn open_replica_recovery_inner(
3477        root: PathBuf,
3478        kek: Option<Arc<crate::encryption::Kek>>,
3479        lock: ExclusiveDatabaseLease,
3480    ) -> Result<Self> {
3481        if !root.join(META_DIR).join("replica").is_file() {
3482            return Err(MongrelError::InvalidArgument(
3483                "recovery auth bypass requires a marked replica staging directory".into(),
3484            ));
3485        }
3486        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3487        let mut cat = catalog::read_durable(
3488            lock.durable_root.as_deref().ok_or_else(|| {
3489                MongrelError::Other("database root descriptor was not pinned".into())
3490            })?,
3491            meta_dek.as_ref(),
3492        )?
3493        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3494        let recovery_checkpoint = cat.clone();
3495        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3496        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3497            lock.durable_root.as_deref().ok_or_else(|| {
3498                MongrelError::Other("database root descriptor was not pinned".into())
3499            })?,
3500            wal_dek.as_ref(),
3501        )?;
3502        recover_ddl_from_records(
3503            &root,
3504            Some(lock.durable_root.as_deref().ok_or_else(|| {
3505                MongrelError::Other("database root descriptor was not pinned".into())
3506            })?),
3507            &mut cat,
3508            meta_dek.as_ref(),
3509            false,
3510            None,
3511            &recovery_records,
3512        )?;
3513        let principal = if cat.require_auth {
3514            cat.users
3515                .iter()
3516                .find(|user| user.is_admin)
3517                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
3518                .ok_or_else(|| {
3519                    MongrelError::Schema(
3520                        "authenticated replica catalog has no recoverable admin".into(),
3521                    )
3522                })?
3523                .into()
3524        } else {
3525            None
3526        };
3527        Self::finish_open(
3528            root,
3529            cat,
3530            kek,
3531            meta_dek,
3532            true,
3533            Some(recovery_checkpoint),
3534            Some(recovery_records),
3535            principal,
3536            lock,
3537            CoreResourceConfig::default(),
3538            OpenModeGate::Normal,
3539        )
3540    }
3541
3542    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
3543    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
3544    ///
3545    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
3546    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
3547    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
3548    ///
3549    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
3550    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
3551    /// caller never blocks past its budget even at the tail of a busy lock
3552    /// holder's lock-window.
3553    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
3554        use fs2::FileExt;
3555        if timeout_ms == 0 {
3556            return f.try_lock_exclusive();
3557        }
3558        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
3559        let deadline =
3560            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
3561        let mut next_sleep = std::time::Duration::from_millis(1);
3562        let mut recorded_wait = false;
3563        loop {
3564            match f.try_lock_exclusive() {
3565                Ok(()) => return Ok(()),
3566                Err(e) if Self::is_file_lock_contended(&e) => {
3567                    if !recorded_wait {
3568                        DATABASE_OPEN_WAIT_COUNT.fetch_add(1, Ordering::Relaxed);
3569                        recorded_wait = true;
3570                    }
3571                    let now = std::time::Instant::now();
3572                    if now >= deadline {
3573                        return Err(std::io::Error::new(
3574                            std::io::ErrorKind::WouldBlock,
3575                            format!("could not acquire database lock within {timeout_ms}ms"),
3576                        ));
3577                    }
3578                    let remaining = deadline - now;
3579                    let sleep = next_sleep.min(remaining);
3580                    std::thread::sleep(sleep);
3581                    // Cap the per-iteration sleep so a single back-off step
3582                    // never overshoots the remaining budget.
3583                    next_sleep = next_sleep
3584                        .saturating_mul(10)
3585                        .min(std::time::Duration::from_millis(50));
3586                }
3587                Err(e) => return Err(e),
3588            }
3589        }
3590    }
3591
3592    fn is_file_lock_contended(error: &std::io::Error) -> bool {
3593        if error.kind() == std::io::ErrorKind::WouldBlock {
3594            return true;
3595        }
3596        #[cfg(windows)]
3597        {
3598            // LockFileEx reports ERROR_LOCK_VIOLATION without mapping it to
3599            // ErrorKind::WouldBlock.
3600            return error.raw_os_error() == Some(33);
3601        }
3602        #[cfg(not(windows))]
3603        false
3604    }
3605
3606    #[allow(clippy::too_many_arguments)]
3607    fn finish_open(
3608        root: PathBuf,
3609        cat: Catalog,
3610        kek: Option<Arc<crate::encryption::Kek>>,
3611        meta_dek: Option<[u8; META_DEK_LEN]>,
3612        existing: bool,
3613        recovery_checkpoint: Option<Catalog>,
3614        recovery_records: Option<Vec<crate::wal::Record>>,
3615        principal: Option<crate::auth::Principal>,
3616        lock: ExclusiveDatabaseLease,
3617        resources: CoreResourceConfig,
3618        mode_gate: OpenModeGate,
3619    ) -> Result<Self> {
3620        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
3621            MongrelError::Other("database root descriptor was not pinned".into())
3622        })?);
3623        // Storage-mode gate (spec section 5.3, Stage 2E): read the durable
3624        // marker before any recovery side effect and fail closed on corrupt
3625        // or forbidden modes. Legacy databases have no marker and are treated
3626        // as Standalone (backfilled below, after recovery succeeds).
3627        let storage_mode = if existing {
3628            crate::storage_mode::read(&durable_root)?
3629        } else {
3630            None
3631        };
3632        match (&mode_gate, &storage_mode) {
3633            (OpenModeGate::Create(_), _) => {}
3634            (OpenModeGate::Normal | OpenModeGate::SharedCore, mode) => {
3635                crate::storage_mode::check_open(mode.as_ref(), false)?
3636            }
3637            (OpenModeGate::OfflineValidation, mode) => {
3638                crate::storage_mode::check_open(mode.as_ref(), true)?
3639            }
3640            (
3641                OpenModeGate::ClusterRuntime {
3642                    cluster_id,
3643                    node_id,
3644                    database_id,
3645                },
3646                Some(actual),
3647            ) => {
3648                let expected = crate::storage_mode::StorageMode::ClusterReplica {
3649                    cluster_id: *cluster_id,
3650                    node_id: *node_id,
3651                    database_id: *database_id,
3652                };
3653                if *actual != expected {
3654                    return Err(
3655                        crate::storage_mode::StorageModeError::IdentityMismatch(format!(
3656                            "expected {expected:?}, marker holds {actual:?}"
3657                        ))
3658                        .into(),
3659                    );
3660                }
3661            }
3662            (OpenModeGate::ClusterRuntime { .. }, None) => {
3663                return Err(crate::storage_mode::StorageModeError::IdentityMismatch(
3664                    "expected a ClusterReplica marker; directory has none".into(),
3665                )
3666                .into());
3667            }
3668        }
3669        let read_only = match &mode_gate {
3670            OpenModeGate::OfflineValidation | OpenModeGate::ClusterRuntime { .. } => true,
3671            // A freshly created cluster replica rejects user writes from the
3672            // start: mutations arrive through the replicated apply path only.
3673            OpenModeGate::Create(mode) => mode.cluster_identity().is_some(),
3674            OpenModeGate::Normal | OpenModeGate::SharedCore => false,
3675        } || if existing {
3676            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
3677                Ok(_) => true,
3678                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
3679                Err(error) => return Err(error.into()),
3680            }
3681        } else {
3682            false
3683        };
3684        let recovered_catalog = cat;
3685        let mut cat = recovered_catalog.clone();
3686        let abandoned = if existing && !read_only {
3687            let abandoned = cat
3688                .tables
3689                .iter()
3690                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
3691                .map(|entry| entry.table_id)
3692                .collect::<Vec<_>>();
3693            for entry in &mut cat.tables {
3694                if abandoned.contains(&entry.table_id) {
3695                    entry.state = TableState::Dropped {
3696                        at_epoch: cat.db_epoch,
3697                    };
3698                }
3699            }
3700            abandoned
3701        } else {
3702            Vec::new()
3703        };
3704        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3705        let recovery_records = match (existing, recovery_records) {
3706            (true, Some(records)) => records,
3707            (true, None) => {
3708                return Err(MongrelError::Other(
3709                    "existing open has no validated WAL recovery plan".into(),
3710                ))
3711            }
3712            (false, _) => Vec::new(),
3713        };
3714        let (history_epochs, history_start) =
3715            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
3716        let open_generation = if existing {
3717            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
3718                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3719            })?;
3720            let recovered_table_ids = cat
3721                .tables
3722                .iter()
3723                .filter(|entry| {
3724                    checkpoint
3725                        .tables
3726                        .iter()
3727                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
3728                })
3729                .map(|entry| entry.table_id)
3730                .collect::<HashSet<_>>();
3731            let reconciled_table_ids = cat
3732                .tables
3733                .iter()
3734                .filter(|entry| {
3735                    checkpoint
3736                        .tables
3737                        .iter()
3738                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
3739                        .is_some_and(|checkpoint| {
3740                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
3741                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
3742                        })
3743                })
3744                .map(|entry| entry.table_id)
3745                .collect::<HashSet<_>>();
3746            validate_shared_wal_recovery_plan(
3747                &durable_root,
3748                &cat,
3749                &recovered_table_ids,
3750                &reconciled_table_ids,
3751                meta_dek.as_ref(),
3752                kek.clone(),
3753                &recovery_records,
3754            )?;
3755            let retained_generation = recovery_records
3756                .iter()
3757                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
3758                .map(|record| record.txn_id >> 32)
3759                .max()
3760                .unwrap_or(0);
3761            let head_generation =
3762                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
3763            let durable_floor = match head_generation {
3764                Some(head) if retained_generation > head => {
3765                    return Err(MongrelError::CorruptWal {
3766                        offset: retained_generation,
3767                        reason: format!(
3768                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
3769                        ),
3770                    })
3771                }
3772                Some(head) => head,
3773                None => retained_generation,
3774            };
3775            let stored = catalog::read_generation(&durable_root)?;
3776            if stored.is_some_and(|generation| generation < durable_floor) {
3777                return Err(MongrelError::Other(format!(
3778                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
3779                )));
3780            }
3781            let bumped = stored
3782                .unwrap_or(durable_floor)
3783                .max(durable_floor)
3784                .checked_add(1)
3785                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
3786            if bumped > u32::MAX as u64 {
3787                return Err(MongrelError::Full(
3788                    "open-generation namespace exhausted".into(),
3789                ));
3790            }
3791            bumped
3792        } else {
3793            0
3794        };
3795        let principal = if cat.require_auth && !matches!(&mode_gate, OpenModeGate::SharedCore) {
3796            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3797            Some(
3798                Self::resolve_bound_principal_from_catalog(&cat, supplied)
3799                    .ok_or(MongrelError::AuthRequired)?,
3800            )
3801        } else {
3802            principal
3803        };
3804        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
3805        if existing {
3806            for entry in &cat.tables {
3807                if !matches!(entry.state, TableState::Live) {
3808                    continue;
3809                }
3810                match durable_root
3811                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
3812                {
3813                    Ok(root) => {
3814                        table_roots.insert(entry.table_id, Arc::new(root));
3815                    }
3816                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3817                    Err(error) => return Err(error.into()),
3818                }
3819            }
3820        }
3821
3822        // No database-tree mutation occurs above this point. DDL, row payloads,
3823        // immutable runs, auth state, retention, and generation state have all
3824        // been validated against the authoritative recovered catalog.
3825        if existing {
3826            let mut applied = recovery_checkpoint.ok_or_else(|| {
3827                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3828            })?;
3829            recover_ddl_from_records(
3830                &root,
3831                Some(&durable_root),
3832                &mut applied,
3833                meta_dek.as_ref(),
3834                true,
3835                Some(&table_roots),
3836                &recovery_records,
3837            )?;
3838            let catalog_value = |catalog: &Catalog| {
3839                serde_json::to_value(catalog)
3840                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
3841            };
3842            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
3843                return Err(MongrelError::CorruptWal {
3844                    offset: 0,
3845                    reason: "validated and applied DDL recovery plans differ".into(),
3846                });
3847            }
3848            if catalog_value(&cat)? != catalog_value(&applied)? {
3849                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3850            }
3851            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
3852            if !read_only {
3853                sweep_unreferenced_table_dirs(&root, &cat)?;
3854            }
3855            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
3856                Ok(()) => {}
3857                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3858                Err(error) => return Err(error.into()),
3859            }
3860        }
3861
3862        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
3863        let snapshots = Arc::new(SnapshotRegistry::new());
3864        snapshots.configure_history(history_epochs, history_start);
3865        // S1E-003: exactly one node-level memory governor per storage core.
3866        // Both caches reserve under it (their live bytes always show up in
3867        // the governor's accounting) and register as reclaimable subsystems
3868        // the governor drives under escalation step 2.
3869        let memory_governor = crate::memory::MemoryGovernor::new(
3870            crate::memory::GovernorConfig::new(resources.memory_budget_bytes),
3871        )
3872        .map_err(|error| MongrelError::InvalidArgument(format!("memory governor: {error}")))?;
3873        // S1E-004: exactly one spill manager per core, rooted at
3874        // `<db-root>/temp/spill`; opening it runs the startup sweep that
3875        // deletes every stale spill file a prior process run left behind.
3876        let spill_manager = crate::spill::SpillManager::open(
3877            &durable_root,
3878            crate::spill::SpillConfig::new(resources.temp_disk_budget_bytes),
3879            meta_dek,
3880        )?;
3881        // S1F-002: exactly one durable job registry per core (sibling `JOBS`
3882        // file). Crash recovery inside `open` parks mid-`Running` jobs as
3883        // `Paused` for an operator-driven resume.
3884        let job_registry = Arc::new(crate::jobs::JobRegistry::open(&root, meta_dek.as_ref())?);
3885        let page_cache = Arc::new(crate::cache::Sharded::new(
3886            crate::cache::CACHE_SHARDS,
3887            || {
3888                crate::cache::PageCache::new(
3889                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3890                )
3891                .with_governor(
3892                    memory_governor.clone(),
3893                    crate::memory::MemoryClass::PageCache,
3894                )
3895            },
3896        ));
3897        memory_governor.register_reclaimable(&page_cache);
3898        let decoded_cache = Arc::new(crate::cache::Sharded::new(
3899            crate::cache::CACHE_SHARDS,
3900            || {
3901                crate::cache::DecodedPageCache::new(
3902                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3903                )
3904                .with_governor(
3905                    memory_governor.clone(),
3906                    crate::memory::MemoryClass::DecodedCache,
3907                )
3908            },
3909        ));
3910        memory_governor.register_reclaimable(&decoded_cache);
3911        let commit_lock = Arc::new(Mutex::new(()));
3912        let shared_wal = Arc::new(Mutex::new(if existing {
3913            crate::wal::SharedWal::open_durable_root_validated(
3914                Arc::clone(&durable_root),
3915                Epoch(cat.db_epoch),
3916                wal_dek.clone(),
3917                Some(&recovery_records),
3918            )?
3919        } else {
3920            crate::wal::SharedWal::create_with_durable_root(
3921                Arc::clone(&durable_root),
3922                Epoch(cat.db_epoch),
3923                wal_dek.clone(),
3924            )?
3925        }));
3926        // Shared write-path state handed to every mounted table so single-table
3927        // `put`/`commit` writes route through the one shared WAL, the one group-
3928        // commit coordinator, and the one poison flag (B1).
3929        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
3930        // S1A-004: the lifecycle is created before the write-path plumbing so
3931        // the group-commit coordinator and every mounted table can poison it on
3932        // an unrecoverable fsync error; `mark_open` happens only once every
3933        // initialization step below has completed.
3934        let lifecycle = Arc::new(crate::core::LifecycleController::new());
3935        let group = Arc::new(
3936            crate::txn::GroupCommit::new(shared_wal.lock().durable_seq())
3937                .with_lifecycle(Arc::clone(&lifecycle)),
3938        );
3939        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
3940        // Final base value is set after the open-generation bump below; tables
3941        // only draw ids once the user issues a write (post-open), so the
3942        // placeholder is never observed.
3943        let txn_ids = Arc::new(Mutex::new(1u64));
3944        let _ = abandoned;
3945        // FND-004 (spec §9.4): the commit-log authority for this database. The
3946        // transaction-id allocator Arc is stable; its base value is re-seeded
3947        // below once the open generation is final. The HLC clock is the node's
3948        // single timestamp authority (spec §4.1, §8.2; ADR-0003): standalone
3949        // mode uses node tiebreaker 0, and the skew bound only engages once
3950        // remote timestamps are observed (Stage 2 replication).
3951        let hlc = Arc::new(mongreldb_types::hlc::HlcClock::new(
3952            0,
3953            std::time::Duration::from_millis(500),
3954        ));
3955        // S1B-005: durable idempotency ledger (sibling `TXN_IDEMPOTENCY` file
3956        // via `DurableRoot::write_atomic`, mirroring `jobs.rs`'s `JOBS`).
3957        let idempotency = crate::txn::IdempotencyLedger::open(Arc::clone(&durable_root), meta_dek)?;
3958        let standalone_commit_log = Arc::new(crate::commit_log::StandaloneCommitLog::new(
3959            Arc::clone(&shared_wal),
3960            Arc::clone(&group),
3961            Arc::clone(&epoch),
3962            Arc::clone(&commit_lock),
3963            Arc::clone(&txn_ids),
3964            root.clone(),
3965            wal_dek.clone(),
3966            Arc::clone(&hlc),
3967        ));
3968        let commit_log: Arc<dyn mongreldb_log::CommitLog> = standalone_commit_log.clone();
3969
3970        // Build the shared auth state early — it's cloned into every mounted
3971        // Table's SharedCtx so the Table layer can enforce permissions without
3972        // a reference back to Database. The `require_auth` flag is mirrored
3973        // from the catalog; `enable_auth` / `refresh_principal` update it live.
3974        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
3975        let security_coordinator = security_coordinator(&root, cat.security_version);
3976        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> =
3977            if matches!(&mode_gate, OpenModeGate::SharedCore) {
3978                // Shared tables are reachable only through DatabaseHandle's
3979                // principal-explicit boundary. A core-wide checker cannot
3980                // represent multiple simultaneous principals.
3981                None
3982            } else {
3983                Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
3984                    auth_state.clone(),
3985                )))
3986            };
3987
3988        // Open every live table against the shared context. Mounted tables have
3989        // no private WAL (B1) — `open_in` just loads the manifest/runs and
3990        // advances the shared epoch authority to its manifest epoch, so the
3991        // final shared watermark is the max across all tables. All of a mounted
3992        // table's committed records are replayed below from the shared WAL.
3993        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
3994        for entry in &cat.tables {
3995            if !matches!(entry.state, TableState::Live) {
3996                continue;
3997            }
3998            let table_root = match table_roots.remove(&entry.table_id) {
3999                Some(root) => root,
4000                None => Arc::new(
4001                    durable_root
4002                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
4003                ),
4004            };
4005            let tdir = table_root.io_path()?;
4006            let ctx = SharedCtx {
4007                root_guard: Some(table_root),
4008                epoch: Arc::clone(&epoch),
4009                page_cache: Arc::clone(&page_cache),
4010                decoded_cache: Arc::clone(&decoded_cache),
4011                snapshots: Arc::clone(&snapshots),
4012                kek: kek.clone(),
4013                commit_lock: Arc::clone(&commit_lock),
4014                shared: Some(crate::engine::SharedWalCtx {
4015                    wal: Arc::clone(&shared_wal),
4016                    group: Arc::clone(&group),
4017                    poisoned: Arc::clone(&poisoned),
4018                    txn_ids: Arc::clone(&txn_ids),
4019                    change_wake: change_wake.clone(),
4020                    lifecycle: Arc::clone(&lifecycle),
4021                }),
4022                table_name: Some(entry.name.clone()),
4023                auth: auth_checker.clone(),
4024                read_only,
4025            };
4026            let t = Table::open_in(&tdir, ctx)?;
4027            tables.insert(entry.table_id, TableHandle::new(t));
4028        }
4029
4030        // Recover transaction writes from the shared WAL (spec §15). This is the
4031        // single durability source for mounted tables: it applies every committed
4032        // record — both single-table `Table::commit` writes and cross-table
4033        // transactions — gated by each table's `flushed_epoch` (records already
4034        // durable in a run are not re-applied).
4035        if existing {
4036            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
4037            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
4038            if read_only {
4039                crate::replication::reconcile_replica_epoch_durable(
4040                    &durable_root,
4041                    epoch.visible().0,
4042                )?;
4043            }
4044            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
4045            // large transactions (spec §8.5, review fix #14).
4046            sweep_pending_txn_dirs(&root, &cat);
4047        }
4048
4049        // Persist only after all semantic recovery and table mounting succeeds.
4050        catalog::write_generation(&durable_root, open_generation)?;
4051        // Storage-mode marker (spec section 5.3): fresh creates record their
4052        // mode; legacy databases (no marker file) are backfilled as Standalone
4053        // on first open. Like the open-generation write above, this is durable
4054        // open bookkeeping, so it also runs for read-only opens.
4055        match &mode_gate {
4056            OpenModeGate::Create(mode) => crate::storage_mode::write(&durable_root, mode)?,
4057            _ if existing && storage_mode.is_none() => {
4058                crate::storage_mode::write(
4059                    &durable_root,
4060                    &crate::storage_mode::StorageMode::Standalone,
4061                )?;
4062            }
4063            _ => {}
4064        }
4065        shared_wal.lock().seal_open_generation(open_generation)?;
4066        crate::replication::replication_identity_durable(&durable_root)?;
4067        let next_txn_id = (open_generation << 32) | 1;
4068        // Seed the shared txn-id allocator now that the generation is final.
4069        *txn_ids.lock() = next_txn_id;
4070        let mut lock = lock;
4071        lock.mark_open()?;
4072
4073        // Initialization is complete: recovery, WAL opening, open-generation
4074        // advancement, and table mounting all happened above, exactly once for
4075        // this core (spec §10.1, S1A-002). The core starts life `Open`.
4076        lifecycle.mark_open();
4077        let core = DatabaseCore {
4078            root,
4079            durable_root,
4080            lifecycle,
4081            registry: std::sync::OnceLock::new(),
4082            read_only,
4083            catalog: RwLock::new(cat),
4084            security_coordinator,
4085            security_catalog_disk_reads: AtomicU64::new(0),
4086            rls_cache: Mutex::new(RlsCache::default()),
4087            epoch,
4088            snapshots,
4089            memory_governor,
4090            page_cache,
4091            decoded_cache,
4092            spill_manager,
4093            resource_groups: crate::resource::ResourceGroupRegistry::with_defaults(),
4094            embedding_providers: crate::embedding::EmbeddingProviderRegistry::new(),
4095            job_registry,
4096            commit_lock,
4097            kms_rotation_lock: Mutex::new(()),
4098            shared_wal,
4099            next_txn_id: txn_ids,
4100            tables: RwLock::new(tables),
4101            kek,
4102            ddl_lock: Mutex::new(()),
4103            meta_dek,
4104            conflicts: crate::txn::ConflictIndex::new(),
4105            active_txns: crate::txn::ActiveTxns::new(),
4106            lock_manager: Arc::new(crate::locks::LockManager::new()),
4107            poisoned,
4108            group,
4109            commit_log: RwLock::new(commit_log),
4110            standalone_commit_log,
4111            hlc,
4112            idempotency,
4113            commit_ts_ledger: Mutex::new(commit_ts_ledger_from_recovery(&recovery_records)),
4114            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
4115            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
4116            replication_barrier: parking_lot::RwLock::new(()),
4117            replication_wal_retention_segments: AtomicUsize::new(0),
4118            backup_pins: Arc::new(Mutex::new(HashMap::new())),
4119            spill_hook: Mutex::new(None),
4120            security_commit_hook: Mutex::new(None),
4121            catalog_commit_hook: Mutex::new(None),
4122            backup_hook: Mutex::new(None),
4123            fk_lock_hook: Mutex::new(None),
4124            replication_hook: Mutex::new(None),
4125            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
4126            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
4127            trigger_max_loop_iterations: AtomicU32::new(
4128                TriggerConfig::default().max_loop_iterations,
4129            ),
4130            notify: {
4131                let (tx, _rx) = tokio::sync::broadcast::channel(256);
4132                tx
4133            },
4134            change_wake,
4135            _lock: Mutex::new(Some(lock)),
4136        };
4137        Ok(Self {
4138            core: Arc::new(core),
4139            principal: RwLock::new(principal),
4140            auth_state,
4141            shared: false,
4142        })
4143    }
4144
4145    /// The current reader-visible epoch.
4146    pub fn visible_epoch(&self) -> Epoch {
4147        self.epoch.visible()
4148    }
4149
4150    /// The catalog's monotonic command version (S1F-001).
4151    pub fn catalog_version(&self) -> u64 {
4152        self.catalog.read().catalog_version
4153    }
4154
4155    /// The durable storage mode recorded for this database (spec section 5.3).
4156    /// `None` only while a legacy pre-marker database is mid-open (the marker
4157    /// is backfilled before open completes).
4158    pub fn storage_mode(&self) -> Result<Option<crate::storage_mode::StorageMode>> {
4159        Ok(crate::storage_mode::read(&self.durable_root)?)
4160    }
4161
4162    /// The core's node-level memory governor (S1E-003). Exactly one per core;
4163    /// the page caches reserve under it and register as reclaimable.
4164    pub fn memory_governor(&self) -> &crate::memory::MemoryGovernor {
4165        &self.memory_governor
4166    }
4167
4168    /// Workload resource-group registry (S1E-002). Seeded with defaults for
4169    /// every [`crate::resource::WorkloadClass`] at open.
4170    pub fn resource_groups(&self) -> &crate::resource::ResourceGroupRegistry {
4171        &self.resource_groups
4172    }
4173
4174    /// Process-local embedding provider registry. Core storage never hard-codes
4175    /// an external vendor; register local/remote providers here when generation
4176    /// is desired. Application-supplied vectors need no registration.
4177    pub fn embedding_providers(&self) -> &crate::embedding::EmbeddingProviderRegistry {
4178        &self.embedding_providers
4179    }
4180
4181    /// Remove a provider only when no live schema references it.
4182    pub fn unregister_embedding_provider(&self, provider_id: &str) -> Result<bool> {
4183        let references = self
4184            .catalog
4185            .read()
4186            .tables
4187            .iter()
4188            .flat_map(|table| &table.schema.columns)
4189            .filter(|column| {
4190                column
4191                    .embedding_source
4192                    .as_ref()
4193                    .and_then(crate::embedding::EmbeddingSource::provider_id)
4194                    == Some(provider_id)
4195            })
4196            .count();
4197        self.embedding_providers
4198            .unregister_unreferenced(provider_id, references)
4199            .map_err(embedding_error)
4200    }
4201
4202    fn materialize_generated_embeddings(
4203        &self,
4204        staging: &mut [(u64, crate::txn::Staged)],
4205        control: Option<&crate::ExecutionControl>,
4206    ) -> Result<()> {
4207        let schemas = {
4208            let catalog = self.catalog.read();
4209            catalog
4210                .tables
4211                .iter()
4212                .map(|table| (table.table_id, table.schema.clone()))
4213                .collect::<HashMap<_, _>>()
4214        };
4215        let fallback_control = crate::ExecutionControl::new(None);
4216        let control = control.unwrap_or(&fallback_control);
4217        for (table_id, staged) in staging {
4218            let (cells, mut update_changed_columns) = match staged {
4219                crate::txn::Staged::Put(cells) => (cells, None),
4220                crate::txn::Staged::Update {
4221                    new_row,
4222                    changed_columns,
4223                    ..
4224                } => (new_row, Some(changed_columns)),
4225                crate::txn::Staged::Delete(_) | crate::txn::Staged::Truncate => continue,
4226            };
4227            let schema = schemas.get(table_id).ok_or_else(|| {
4228                MongrelError::Schema(format!("table id {table_id} disappeared during embedding"))
4229            })?;
4230            for target in &schema.columns {
4231                let Some(crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec }) =
4232                    target.embedding_source.as_ref()
4233                else {
4234                    continue;
4235                };
4236                let text = render_embedding_input(schema, spec, cells)?;
4237                let reservation_bytes = text
4238                    .len()
4239                    .saturating_add((spec.dimension as usize).saturating_mul(4));
4240                let _reservation = self
4241                    .memory_governor
4242                    .try_reserve(
4243                        reservation_bytes as u64,
4244                        crate::memory::MemoryClass::AiCandidates,
4245                    )
4246                    .map_err(|error| MongrelError::ResourceLimitExceeded {
4247                        resource: "embedding memory",
4248                        requested: reservation_bytes,
4249                        limit: match error {
4250                            crate::memory::MemoryError::Exhausted { available, .. } => {
4251                                available as usize
4252                            }
4253                            _ => 0,
4254                        },
4255                    })?;
4256                let source =
4257                    crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec: spec.clone() };
4258                let provider = self
4259                    .embedding_providers
4260                    .resolve(&source)
4261                    .map_err(embedding_error)?;
4262                let vectors = self
4263                    .embedding_providers
4264                    .embed_controlled(
4265                        &source,
4266                        &[text.as_str()],
4267                        spec.dimension,
4268                        control,
4269                        "generated-column-write",
4270                        crate::embedding::EmbeddingLimits::default(),
4271                    )
4272                    .map_err(embedding_error)?;
4273                cells.retain(|(column, _)| *column != target.id);
4274                cells.push((
4275                    target.id,
4276                    crate::memtable::Value::GeneratedEmbedding(Box::new(
4277                        crate::embedding::GeneratedEmbeddingValue {
4278                            vector: vectors.into_iter().next().ok_or_else(|| {
4279                                MongrelError::Other("embedding provider returned no vector".into())
4280                            })?,
4281                            metadata: crate::embedding::GeneratedEmbeddingMetadata {
4282                                provider_id: provider.provider_id().to_owned(),
4283                                model_id: provider.model_id().to_owned(),
4284                                model_version: provider.model_version().to_owned(),
4285                                preprocessing_version: provider.preprocessing_version().to_owned(),
4286                                source_fingerprint: Sha256::digest(text.as_bytes()).into(),
4287                                status: crate::embedding::EmbeddingGenerationStatus::Ready,
4288                                last_error_category: None,
4289                                attempt_count: 1,
4290                            },
4291                        },
4292                    )),
4293                ));
4294                if let Some(changed_columns) = update_changed_columns.as_deref_mut() {
4295                    changed_columns.push(target.id);
4296                }
4297            }
4298            if let Some(changed_columns) = update_changed_columns {
4299                changed_columns.sort_unstable();
4300                changed_columns.dedup();
4301            }
4302        }
4303        Ok(())
4304    }
4305
4306    /// Acquire exclusive row locks for `SELECT ... FOR UPDATE` (spec §10.2).
4307    /// Locks are held until the transaction ends (`release_txn_locks` /
4308    /// commit / abort). Requires an open transaction id from the SQL session.
4309    pub fn lock_rows_for_update(
4310        &self,
4311        txn_id: u64,
4312        table_id: u64,
4313        row_ids: &[crate::rowid::RowId],
4314        control: Option<&crate::ExecutionControl>,
4315    ) -> Result<()> {
4316        for &row_id in row_ids {
4317            self.acquire_txn_lock(
4318                txn_id,
4319                crate::locks::LockKey::row(table_id, row_id),
4320                crate::locks::LockMode::Exclusive,
4321                control,
4322            )?;
4323        }
4324        Ok(())
4325    }
4326
4327    /// The core's node-level spill manager (S1E-004), rooted at
4328    /// `<db-root>/temp/spill`. Query engines open per-query spill sessions
4329    /// from it.
4330    pub fn spill_manager(&self) -> &crate::spill::SpillManager {
4331        &self.spill_manager
4332    }
4333
4334    /// The core's durable job registry (S1F-002, sibling `JOBS` file).
4335    pub fn job_registry(&self) -> &Arc<crate::jobs::JobRegistry> {
4336        &self.job_registry
4337    }
4338
4339    /// S1C-004 diagnostics: every mounted table's active version-retention
4340    /// pin sources, aggregated from [`crate::engine::Table::version_pins_report`].
4341    /// A version may be reclaimed only when it is older than the oldest pin of
4342    /// every source; this report exposes each of them per table.
4343    pub fn version_pins_report(&self) -> Vec<TablePinsReport> {
4344        let names: HashMap<u64, String> = self
4345            .catalog
4346            .read()
4347            .tables
4348            .iter()
4349            .map(|entry| (entry.table_id, entry.name.clone()))
4350            .collect();
4351        let handles: Vec<_> = self
4352            .tables
4353            .read()
4354            .iter()
4355            .map(|(table_id, handle)| (*table_id, handle.clone()))
4356            .collect();
4357        handles
4358            .into_iter()
4359            .map(|(table_id, handle)| {
4360                let pins = handle.lock().version_pins_report();
4361                TablePinsReport {
4362                    table_id,
4363                    table: names.get(&table_id).cloned().unwrap_or_default(),
4364                    pins,
4365                }
4366            })
4367            .collect()
4368    }
4369
4370    /// The core's key/predicate lock manager (S1B-003).
4371    pub fn lock_manager(&self) -> &Arc<crate::locks::LockManager> {
4372        &self.lock_manager
4373    }
4374
4375    /// S1B-004 step 12: release every lock `txn_id` holds (commit, abort, and
4376    /// rollback all funnel here). Idempotent — releasing a transaction with
4377    /// no holds is a no-op.
4378    /// Release every lock held by `txn_id` (COMMIT/ROLLBACK/FOR UPDATE end).
4379    pub fn release_txn_locks(&self, txn_id: u64) {
4380        self.lock_manager.release_all(txn_id);
4381    }
4382
4383    /// Build a lock request for `txn_id`, inheriting the commit's
4384    /// cancellation control (or a plain one when the commit is uncontrolled).
4385    fn txn_lock_request(
4386        txn_id: u64,
4387        mode: crate::locks::LockMode,
4388        control: Option<&crate::ExecutionControl>,
4389    ) -> crate::locks::LockRequest {
4390        let control = control
4391            .cloned()
4392            .unwrap_or_else(|| crate::ExecutionControl::new(None));
4393        crate::locks::LockRequest::new(txn_id, mode, control)
4394    }
4395
4396    /// Acquire one lock for `txn_id`, bridging the typed [`crate::locks::LockError`]
4397    /// onto the engine error (deadlock victims surface as
4398    /// [`MongrelError::Deadlock`] with [`mongreldb_types::error::ErrorCategory::Deadlock`]).
4399    pub(crate) fn acquire_txn_lock(
4400        &self,
4401        txn_id: u64,
4402        key: crate::locks::LockKey,
4403        mode: crate::locks::LockMode,
4404        control: Option<&crate::ExecutionControl>,
4405    ) -> Result<()> {
4406        self.lock_manager
4407            .acquire(key, Self::txn_lock_request(txn_id, mode, control))
4408            .map_err(MongrelError::from)
4409    }
4410
4411    /// S1B-003: acquire the schema barrier Exclusive for one DDL operation.
4412    /// The hold releases when the returned guard drops (end of the DDL entry
4413    /// point). DML transactions hold the barrier Shared for their whole
4414    /// commit, so schema changes exclude concurrent DML and one another.
4415    fn acquire_schema_barrier_exclusive(&self) -> Result<TxnLockGuard<'_>> {
4416        let txn_id = self.alloc_txn_id()?;
4417        self.acquire_txn_lock(
4418            txn_id,
4419            crate::locks::LockKey::schema_barrier(),
4420            crate::locks::LockMode::Exclusive,
4421            None,
4422        )?;
4423        Ok(TxnLockGuard {
4424            locks: &self.lock_manager,
4425            txn_id,
4426        })
4427    }
4428
4429    /// The commit-log authority for this database (spec §9.4, FND-004). Every
4430    /// commit path proposes through it and visibility publication is gated on
4431    /// its receipts; Stage 2 swaps this standalone adapter for the replicated
4432    /// implementation behind the same `CommitLog` trait.
4433    pub fn commit_log(&self) -> Arc<dyn mongreldb_log::CommitLog> {
4434        Arc::clone(&self.commit_log.read())
4435    }
4436
4437    /// Bind a cluster replica core to its owning consensus commit authority.
4438    ///
4439    /// Standalone roots reject substitution. Cluster replica user mutations
4440    /// remain read-only; this binding exposes the same authoritative log to
4441    /// runtime diagnostics and future proposal surfaces while committed apply
4442    /// stays the only engine mutation path.
4443    pub fn bind_cluster_commit_log(
4444        &self,
4445        commit_log: Arc<dyn mongreldb_log::CommitLog>,
4446    ) -> Result<()> {
4447        match self.storage_mode()? {
4448            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4449                *self.commit_log.write() = commit_log;
4450                Ok(())
4451            }
4452            mode => Err(MongrelError::InvalidArgument(format!(
4453                "commit-log substitution requires ClusterReplica storage, got {mode:?}"
4454            ))),
4455        }
4456    }
4457
4458    /// Break the cluster commit-log binding during replica-runtime teardown.
4459    ///
4460    /// The replacement standalone adapter is retained only as an inert
4461    /// lifecycle anchor. Cluster replica writes remain rejected, and a
4462    /// restarted runtime must bind its new Raft authority before serving.
4463    pub fn detach_cluster_commit_log(&self) -> Result<()> {
4464        match self.storage_mode()? {
4465            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4466                let standalone: Arc<dyn mongreldb_log::CommitLog> =
4467                    self.standalone_commit_log.clone();
4468                *self.commit_log.write() = standalone;
4469                Ok(())
4470            }
4471            mode => Err(MongrelError::InvalidArgument(format!(
4472                "commit-log detachment requires ClusterReplica storage, got {mode:?}"
4473            ))),
4474        }
4475    }
4476
4477    /// The node's HLC timestamp authority (spec §8.2, ADR-0003). Transaction
4478    /// `begin` captures read timestamps here; the commit sequencer allocates
4479    /// commit timestamps from the same clock so commit ts > every participant
4480    /// read/write timestamp of the transaction.
4481    pub(crate) fn hlc_clock(&self) -> &mongreldb_types::hlc::HlcClock {
4482        &self.hlc
4483    }
4484
4485    /// Clone the in-memory catalog (for diagnostics / tests).
4486    pub fn catalog_snapshot(&self) -> Catalog {
4487        self.catalog.read().clone()
4488    }
4489
4490    /// Read SQLite-compatible application metadata persisted in the catalog.
4491    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
4492        let catalog = self.catalog.read();
4493        match key {
4494            "user_version" => Ok(catalog.user_version),
4495            "application_id" => Ok(catalog.application_id),
4496            _ => Err(MongrelError::InvalidArgument(format!(
4497                "unsupported persistent SQL pragma {key:?}"
4498            ))),
4499        }
4500    }
4501
4502    /// Persist SQLite-compatible application metadata and return its exact
4503    /// publication epoch. An unchanged value performs no durable write.
4504    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
4505        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
4506    }
4507
4508    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
4509        &self,
4510        key: &str,
4511        value: i64,
4512        mut before_commit: F,
4513    ) -> Result<Option<Epoch>>
4514    where
4515        F: FnMut() -> Result<()>,
4516    {
4517        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
4518    }
4519
4520    fn set_sql_pragma_i64_with_epoch_inner(
4521        &self,
4522        key: &str,
4523        value: i64,
4524        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
4525    ) -> Result<Option<Epoch>> {
4526        use crate::wal::DdlOp;
4527
4528        self.require(&crate::auth::Permission::Ddl)?;
4529        if self.read_only {
4530            return Err(MongrelError::ReadOnlyReplica);
4531        }
4532        if self.poisoned.load(Ordering::Relaxed) {
4533            return Err(MongrelError::Other(
4534                "database poisoned by fsync error".into(),
4535            ));
4536        }
4537        let _ddl = self.ddl_lock.lock();
4538        let _security_write = self.security_write()?;
4539        self.require(&crate::auth::Permission::Ddl)?;
4540        let mut next_catalog = self.catalog.read().clone();
4541        let target = match key {
4542            "user_version" => &mut next_catalog.user_version,
4543            "application_id" => &mut next_catalog.application_id,
4544            _ => {
4545                return Err(MongrelError::InvalidArgument(format!(
4546                    "unsupported persistent SQL pragma {key:?}"
4547                )))
4548            }
4549        };
4550        if *target == Some(value) {
4551            return Ok(None);
4552        }
4553        *target = Some(value);
4554
4555        let _commit = self.commit_lock.lock();
4556        let epoch = self.epoch.bump_assigned();
4557        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4558        let txn_id = self.alloc_txn_id()?;
4559        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4560        let commit_seq = {
4561            let mut wal = self.shared_wal.lock();
4562            if let Some(before_commit) = before_commit {
4563                before_commit()?;
4564            }
4565            let append: Result<u64> = (|| {
4566                wal.append(
4567                    txn_id,
4568                    WAL_TABLE_ID,
4569                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
4570                        key: key.to_string(),
4571                        value,
4572                    }),
4573                )?;
4574                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4575                wal.append_commit(txn_id, epoch, &[])
4576            })();
4577            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4578        };
4579        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4580        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4581        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
4582        Ok(Some(epoch))
4583    }
4584
4585    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
4586        self.catalog
4587            .read()
4588            .materialized_views
4589            .iter()
4590            .find(|definition| definition.name == name)
4591            .cloned()
4592    }
4593
4594    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
4595        self.catalog.read().materialized_views.clone()
4596    }
4597
4598    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
4599        self.catalog.read().security.clone()
4600    }
4601
4602    pub fn security_active_for(&self, table: &str) -> bool {
4603        self.catalog.read().security.table_has_security(table)
4604    }
4605
4606    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
4607        if self.catalog.read().security_version == expected_version {
4608            return Ok(());
4609        }
4610        self.security_catalog_disk_reads
4611            .fetch_add(1, Ordering::Relaxed);
4612        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
4613            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
4614        let principal = self.principal.read().clone();
4615        let principal = if fresh.require_auth
4616            && principal
4617                .as_ref()
4618                .is_some_and(|principal| principal.user_id != 0)
4619        {
4620            principal
4621                .as_ref()
4622                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
4623        } else {
4624            principal
4625        };
4626        self.auth_state.set_require_auth(fresh.require_auth);
4627        *self.catalog.write() = fresh;
4628        *self.principal.write() = principal.clone();
4629        self.auth_state.set_principal(principal);
4630        Ok(())
4631    }
4632
4633    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
4634        let guard = self.security_coordinator.gate.write();
4635        let version = self.security_coordinator.version.load(Ordering::Acquire);
4636        self.refresh_security_catalog_if_stale(version)?;
4637        Ok(guard)
4638    }
4639
4640    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
4641    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
4642    /// only its restart checkpoint.
4643    /// S1A-004: admit one operation against the core (rejects once the core
4644    /// leaves [`crate::core::LifecycleState::Open`] — draining, closing,
4645    /// closed, or poisoned). The returned guard holds the operation slot
4646    /// until dropped, so `shutdown()` drains exactly the covered operations.
4647    ///
4648    /// Covered set (the mutating/durable paths; the legacy fsync-poison error
4649    /// still wins where a body already checks `self.poisoned`, because the
4650    /// guard is taken after that check):
4651    ///
4652    /// - the cross-table commit funnel
4653    ///   (`commit_transaction_with_external_states_inner`), covering every
4654    ///   user and internal transaction commit;
4655    /// - the catalog publish funnel (`publish_catalog_candidate_with_prelude`),
4656    ///   covering the user/role/grant, trigger, and procedure families;
4657    /// - the inline-WAL DDL bodies: table create/drop/rename/alter, CTAS
4658    ///   rebuilding publish, security-catalog and materialized-view
4659    ///   replacement, external-table create/drop/reset;
4660    /// - storage maintenance: `gc`, `checkpoint`, `compact`, `hot_backup`;
4661    /// - replication bootstrap, batch extraction, and follow-apply.
4662    ///
4663    /// Read-only entry points (snapshots, queries, stats) and the infallible
4664    /// `begin*` constructors are intentionally not covered: reads never block
4665    /// shutdown, and a transaction's durable act is its commit.
4666    pub(crate) fn admit_operation(&self) -> Result<crate::core::OperationGuard> {
4667        self.core.operation_guard()
4668    }
4669
4670    /// S1F-001: wrap `command` in its versioned record and apply it to the
4671    /// catalog candidate (validate → mutate → bump `catalog_version` → append
4672    /// the bounded command history). Security-catalog changes advance the
4673    /// security version inside `CatalogDelta::apply_to`, exactly where the
4674    /// legacy mutation bodies called `advance_security_version` themselves.
4675    fn apply_catalog_command_to(
4676        &self,
4677        next_catalog: &mut Catalog,
4678        command: crate::catalog_cmds::CatalogCommand,
4679    ) -> Result<crate::catalog_cmds::CatalogDelta> {
4680        let record = crate::catalog_cmds::CatalogCommandRecord::next(next_catalog, command);
4681        next_catalog.apply_command(&record)
4682    }
4683
4684    /// Stage 2E (spec section 11.5): apply one committed replicated
4685    /// transaction's staged records through the **same** logic the WAL
4686    /// recovery path uses ([`recover_shared_wal`]).
4687    ///
4688    /// The payload is a complete record sequence of one committed transaction
4689    /// (data ops, `Op::CommitTimestamp`, and exactly one trailing
4690    /// `Op::TxnCommit`), carrying the leader-assigned commit epoch so every
4691    /// replica applies byte-identical records deterministically.
4692    ///
4693    /// Application is durable before it returns: the records are appended
4694    /// verbatim to the core's shared WAL and group-synced, so the raft state
4695    /// machine's post-apply checkpoint never acknowledges rows a crash would
4696    /// lose. Application is idempotent: a payload whose commit epoch is at or
4697    /// below the core's visible watermark is a replay (the state machine
4698    /// dispatches sink-first, checkpoints second — a crash in that window
4699    /// redelivers) and is skipped without side effects. Returns `Ok(true)`
4700    /// when the payload was applied, `Ok(false)` for a recognized replay.
4701    pub fn apply_replicated_records(&self, records: &[crate::wal::Record]) -> Result<bool> {
4702        use crate::wal::Op;
4703        use std::sync::atomic::Ordering;
4704
4705        let _operation = self.admit_operation()?;
4706        if self.poisoned.load(Ordering::Relaxed) {
4707            return Err(MongrelError::Other(
4708                "database poisoned by fsync error".into(),
4709            ));
4710        }
4711        // Structural validation (fail closed): one transaction, exactly one
4712        // commit marker, at the tail.
4713        let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
4714            MongrelError::InvalidArgument("replicated transaction payload is empty".into())
4715        })?;
4716        if records.iter().any(|record| record.txn_id != txn_id) {
4717            return Err(MongrelError::InvalidArgument(
4718                "replicated transaction payload mixes transaction ids".into(),
4719            ));
4720        }
4721        let commits = records
4722            .iter()
4723            .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
4724            .count();
4725        if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
4726            return Err(MongrelError::InvalidArgument(
4727                "replicated transaction payload must end in exactly one commit marker".into(),
4728            ));
4729        }
4730        let commit_epoch = match records.last().map(|r| &r.op) {
4731            Some(Op::TxnCommit { epoch, .. }) => *epoch,
4732            _ => unreachable!("validated above"),
4733        };
4734        // Fail closed on spilled-run linking: an `added_runs` commit links
4735        // run files that exist only on the leader. Leaders never emit such a
4736        // payload — the Stage 2C envelope builder passes every commit through
4737        // [`translate_records_for_replication`], which stages the spilled rows
4738        // as logical `Op::Put` records and strips the run links (spec section
4739        // 11.3 step 3). This check is the defense-in-depth gate behind that
4740        // contract: a payload carrying run references is un-appliable here
4741        // and is rejected deterministically rather than diverging.
4742        if let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) {
4743            if !added_runs.is_empty() {
4744                return Err(MongrelError::InvalidArgument(
4745                    "replicated spilled-run commits are not appliable: the leader must translate \
4746                     the commit through translate_records_for_replication before proposal"
4747                        .into(),
4748                ));
4749            }
4750        }
4751        // Replay guard: the leader assigns one monotonically increasing epoch
4752        // per committed transaction in log order, so anything at or below the
4753        // core's watermark is already applied.
4754        if commit_epoch <= self.epoch.visible().0 {
4755            return Ok(false);
4756        }
4757        // Stage durable first: verbatim WAL append + fsync. The raft log is
4758        // the commit authority; the local WAL is this replica's durable
4759        // staging of already-committed commands (restart recovery replays it
4760        // through the identical path, gated by flushed epochs).
4761        {
4762            let mut wal = self.shared_wal.lock();
4763            for record in records {
4764                wal.append(record.txn_id, 0, record.op.clone())?;
4765            }
4766            if let Err(error) = wal.group_sync() {
4767                self.poisoned.store(true, Ordering::Relaxed);
4768                return Err(error);
4769            }
4770        }
4771        // S1C-004: pin the pre-apply visible epoch for the duration of apply so
4772        // concurrent GC cannot reclaim versions a catch-up reader still needs.
4773        let replication_pins: Vec<crate::retention::PinGuard> = {
4774            let tables = self.tables.read();
4775            let floor = Epoch(self.epoch.visible().0);
4776            tables
4777                .values()
4778                .map(|handle| {
4779                    let t = handle.lock();
4780                    Arc::clone(t.pin_registry())
4781                        .pin(crate::retention::PinSource::Replication, floor)
4782                })
4783                .collect()
4784        };
4785        let tables = self.tables.read().clone();
4786        let catalog = self.catalog.read().clone();
4787        recover_shared_wal(&self.durable_root, &tables, &catalog, &self.epoch, records)?;
4788        // Replica-side commit-ts ledger: record the leader-assigned commit
4789        // epoch's physical time from any CommitTimestamp op, else stamp from
4790        // the local HLC so `commit_ts_for_epoch` serves PITR/read-your-writes
4791        // on replicas (Stage 2 residual).
4792        if let Some(Op::TxnCommit { epoch, .. }) = records.last().map(|r| &r.op) {
4793            let commit_ts = records
4794                .iter()
4795                .rev()
4796                .find_map(|record| match &record.op {
4797                    Op::CommitTimestamp { unix_nanos } => {
4798                        Some(mongreldb_types::hlc::HlcTimestamp {
4799                            physical_micros: unix_nanos / 1_000,
4800                            logical: 0,
4801                            node_tiebreaker: 0,
4802                        })
4803                    }
4804                    _ => None,
4805                })
4806                .unwrap_or_else(|| {
4807                    self.hlc
4808                        .now()
4809                        .unwrap_or(mongreldb_types::hlc::HlcTimestamp {
4810                            physical_micros: 0,
4811                            logical: 0,
4812                            node_tiebreaker: 0,
4813                        })
4814                });
4815            self.record_commit_ts(Epoch(*epoch), commit_ts);
4816        }
4817        drop(replication_pins);
4818        Ok(true)
4819    }
4820
4821    /// Stage 3H engine binding (spec section 12.8): validate staged
4822    /// write-intent payloads at prepare time. A participant that durably
4823    /// prepares these payloads must be able to apply them at resolution, so
4824    /// every check the resolution apply performs runs here first: each
4825    /// payload decodes as a [`StagedTxnWrite`], its table is mounted, and
4826    /// every staged row satisfies the same persisted-row validation the WAL
4827    /// recovery path applies. A malformed payload is rejected deterministically
4828    /// at prepare — never after a decision commits, where a rejection would
4829    /// wedge the replicated apply stream.
4830    pub fn validate_staged_txn_writes(&self, staged: &[Vec<u8>]) -> Result<()> {
4831        let tables = self.tables.read();
4832        for payload in staged {
4833            match StagedTxnWrite::decode(payload)? {
4834                StagedTxnWrite::Put { table_id, rows } => {
4835                    let handle = tables.get(&table_id).ok_or_else(|| {
4836                        MongrelError::InvalidArgument(format!(
4837                            "staged write targets unmounted table {table_id}"
4838                        ))
4839                    })?;
4840                    let rows: Vec<crate::memtable::Row> =
4841                        bincode::deserialize(&rows).map_err(|error| {
4842                            MongrelError::InvalidArgument(format!(
4843                                "staged put payload for table {table_id} cannot decode: {error}"
4844                            ))
4845                        })?;
4846                    let schema = handle.lock().schema().clone();
4847                    for row in &rows {
4848                        validate_recovered_row(&schema, row).map_err(|error| {
4849                            MongrelError::InvalidArgument(format!(
4850                                "staged row for table {table_id} is not appliable: {error}"
4851                            ))
4852                        })?;
4853                    }
4854                }
4855                StagedTxnWrite::Delete { table_id, row_ids } => {
4856                    if !tables.contains_key(&table_id) {
4857                        return Err(MongrelError::InvalidArgument(format!(
4858                            "staged delete targets unmounted table {table_id}"
4859                        )));
4860                    }
4861                    if row_ids.contains(&u64::MAX) {
4862                        return Err(MongrelError::InvalidArgument(format!(
4863                            "staged delete for table {table_id} names an exhausted row id"
4864                        )));
4865                    }
4866                }
4867            }
4868        }
4869        Ok(())
4870    }
4871
4872    /// Stage 3H engine binding (spec section 12.8): apply one committed
4873    /// resolution's staged writes through the replicated apply path
4874    /// ([`Database::apply_replicated_records`]) at the decision's commit
4875    /// timestamp. `txn_tag` is the caller's deterministic per-transaction
4876    /// tag (e.g. a hash of the distributed transaction id); it must be
4877    /// identical on every replica. The synthetic WAL transaction id is
4878    /// derived from it inside the current open-generation namespace with the
4879    /// allocator-avoidance bit set, so resolution records pass the
4880    /// open-generation durability check on reopen and never alias
4881    /// leader-allocated transaction ids.
4882    ///
4883    /// The commit epoch stamped into the synthetic commit marker is one past
4884    /// the core's visible watermark: the replicated apply stream is
4885    /// deterministic, so every replica computes the identical epoch at the
4886    /// identical apply point, and the core's own restart recovery replays the
4887    /// stamped sequence verbatim. Rows are restamped at that epoch by the
4888    /// recovery logic, becoming visible exactly as an ordinary committed
4889    /// transaction's rows.
4890    pub fn apply_staged_txn_writes(
4891        &self,
4892        txn_tag: u64,
4893        staged: &[Vec<u8>],
4894        commit_ts: mongreldb_types::hlc::HlcTimestamp,
4895    ) -> Result<bool> {
4896        use crate::wal::Op;
4897
4898        // Decode every payload before any mutation (fail closed).
4899        let mut writes = Vec::with_capacity(staged.len());
4900        for payload in staged {
4901            writes.push(StagedTxnWrite::decode(payload)?);
4902        }
4903        // Generation-namespace the synthetic transaction id: the high 32 bits
4904        // are the current open generation (the reopen path rejects retained
4905        // records from a generation beyond the durable WAL head); the low 32
4906        // bits carry the caller's tag with the top bit set, outside the
4907        // ascending leader-allocated counter space.
4908        let generation = *self.next_txn_id.lock() >> 32;
4909        let txn_id = (generation << 32) | (txn_tag & 0x7FFF_FFFF) | 0x8000_0000;
4910        let mut records = Vec::with_capacity(writes.len() + 2);
4911        for write in writes {
4912            let op = match write {
4913                StagedTxnWrite::Put { table_id, rows } => Op::Put { table_id, rows },
4914                StagedTxnWrite::Delete { table_id, row_ids } => Op::Delete {
4915                    table_id,
4916                    row_ids: row_ids.into_iter().map(crate::RowId).collect(),
4917                },
4918            };
4919            records.push(crate::wal::Record::new(Epoch(0), txn_id, op));
4920        }
4921        let epoch = self.epoch.visible().0 + 1;
4922        // The physical component of the decision's commit timestamp goes into
4923        // the durable timestamp ledger, mirroring the ordinary commit path
4924        // (`commit_log::commit_nanos`).
4925        let unix_nanos = commit_ts.physical_micros.saturating_mul(1_000);
4926        records.push(crate::wal::Record::new(
4927            Epoch(0),
4928            txn_id,
4929            Op::CommitTimestamp { unix_nanos },
4930        ));
4931        records.push(crate::wal::Record::new(
4932            Epoch(0),
4933            txn_id,
4934            Op::TxnCommit {
4935                epoch,
4936                added_runs: Vec::new(),
4937            },
4938        ));
4939        let applied = self.apply_replicated_records(&records)?;
4940        if applied {
4941            self.record_commit_ts(Epoch(epoch), commit_ts);
4942        }
4943        Ok(applied)
4944    }
4945
4946    /// Stage 2E (spec sections 10.6, 11.5): apply one committed replicated
4947    /// catalog command and checkpoint the catalog. The record travels as the
4948    /// payload of a replicated `Catalog` command envelope and routes through
4949    /// [`Catalog::apply_command`] — the S1F-001 versioned, idempotent command
4950    /// path (replaying an already-applied `catalog_version` is a no-op).
4951    /// Structural deltas are mirrored into the mounted table set: a created
4952    /// table is mounted, a dropped table unmounted. Deterministic: the record
4953    /// carries every resolved value (ids, epochs, complete images).
4954    pub fn apply_replicated_catalog_command(
4955        &self,
4956        record: &crate::catalog_cmds::CatalogCommandRecord,
4957    ) -> Result<crate::catalog_cmds::CatalogDelta> {
4958        let _operation = self.admit_operation()?;
4959        let _g = self.ddl_lock.lock();
4960        let mut next_catalog = self.catalog.read().clone();
4961        let delta = next_catalog.apply_command(record)?;
4962        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
4963            return Ok(delta);
4964        }
4965        // The leader references epochs in structural commands (a table's
4966        // creation/drop epoch). The replica's epoch stream must cover them:
4967        // epochs stay the commit sequencer's authority, so commands never
4968        // allocate one, but the watermark advances to every referenced epoch
4969        // (mirroring how `create_table_with_state` bumps `db_epoch`).
4970        let referenced_epoch = match &delta {
4971            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => Some(entry.created_epoch),
4972            crate::catalog_cmds::CatalogDelta::TableDropped { at_epoch, .. }
4973            | crate::catalog_cmds::CatalogDelta::TableRenamed { at_epoch, .. } => Some(*at_epoch),
4974            _ => None,
4975        };
4976        if let Some(referenced) = referenced_epoch {
4977            self.epoch.advance_recovered(Epoch(referenced));
4978            next_catalog.db_epoch = next_catalog.db_epoch.max(referenced);
4979        }
4980        match &delta {
4981            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => {
4982                // Guard against a repeated mount within one open (the state
4983                // machine's crash-window redispatch is filtered by the NoOp
4984                // arm above; this covers a catalog checkpoint that never
4985                // became durable before a crash).
4986                if !self.tables.read().contains_key(&entry.table_id) {
4987                    self.mount_catalog_entry(entry)?;
4988                }
4989            }
4990            crate::catalog_cmds::CatalogDelta::TableDropped { table_id, .. } => {
4991                self.tables.write().remove(table_id);
4992            }
4993            _ => {}
4994        }
4995        // Durable BEFORE return: the catalog checkpoint is the only local
4996        // durable record of the command (replicated catalog commands do not
4997        // ride the local WAL), and the state machine checkpoints right after.
4998        catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref())?;
4999        *self.catalog.write() = next_catalog;
5000        Ok(delta)
5001    }
5002
5003    /// Mount a table that a replicated catalog command created (Stage 2E):
5004    /// create the table directory and build the mounted table exactly like
5005    /// the create-table path does, minus the standalone WAL/commit side
5006    /// effects (the command itself is already durable in the raft log).
5007    fn mount_catalog_entry(&self, entry: &crate::catalog::CatalogEntry) -> Result<()> {
5008        let table_relative = Path::new(TABLES_DIR).join(entry.table_id.to_string());
5009        let table_root = Arc::new(
5010            self.durable_root
5011                .create_directory_all_pinned(&table_relative)?,
5012        );
5013        let tdir = table_root.io_path()?;
5014        let ctx = SharedCtx {
5015            root_guard: Some(table_root),
5016            epoch: Arc::clone(&self.epoch),
5017            page_cache: Arc::clone(&self.page_cache),
5018            decoded_cache: Arc::clone(&self.decoded_cache),
5019            snapshots: Arc::clone(&self.snapshots),
5020            kek: self.kek.clone(),
5021            commit_lock: Arc::clone(&self.commit_lock),
5022            shared: Some(crate::engine::SharedWalCtx {
5023                wal: Arc::clone(&self.shared_wal),
5024                group: Arc::clone(&self.group),
5025                poisoned: Arc::clone(&self.poisoned),
5026                txn_ids: Arc::clone(&self.next_txn_id),
5027                change_wake: self.change_wake.clone(),
5028                lifecycle: Arc::clone(&self.lifecycle),
5029            }),
5030            table_name: Some(entry.name.clone()),
5031            auth: self.table_auth_checker(),
5032            read_only: self.read_only,
5033        };
5034        let table = Table::create_in(&tdir, entry.schema.clone(), entry.table_id, ctx)?;
5035        self.tables
5036            .write()
5037            .insert(entry.table_id, TableHandle::new(table));
5038        Ok(())
5039    }
5040
5041    fn publish_catalog_candidate(
5042        &self,
5043        catalog: Catalog,
5044        epoch: Epoch,
5045        epoch_guard: &mut EpochGuard<'_>,
5046        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5047    ) -> Result<()> {
5048        self.publish_catalog_candidate_with_prelude(
5049            catalog,
5050            epoch,
5051            epoch_guard,
5052            before_publish,
5053            Vec::new(),
5054        )
5055    }
5056
5057    fn publish_catalog_candidate_with_prelude(
5058        &self,
5059        catalog: Catalog,
5060        epoch: Epoch,
5061        epoch_guard: &mut EpochGuard<'_>,
5062        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5063        prelude: Vec<(u64, crate::wal::Op)>,
5064    ) -> Result<()> {
5065        use crate::wal::DdlOp;
5066
5067        if self.read_only {
5068            return Err(MongrelError::ReadOnlyReplica);
5069        }
5070        if self.poisoned.load(Ordering::Relaxed) {
5071            return Err(MongrelError::Other(
5072                "database poisoned by fsync error".into(),
5073            ));
5074        }
5075        // S1A-004: admit the catalog publish as one core operation.
5076        let _operation = self.admit_operation()?;
5077        if let Some(before_publish) = before_publish.as_mut() {
5078            (**before_publish)()?;
5079        }
5080        if catalog.db_epoch != epoch.0 {
5081            return Err(MongrelError::InvalidArgument(format!(
5082                "catalog epoch {} does not match commit epoch {}",
5083                catalog.db_epoch, epoch.0
5084            )));
5085        }
5086        {
5087            let current = self.catalog.read();
5088            validate_catalog_transition(&current, &catalog)?;
5089        }
5090        validate_recovered_catalog(&catalog)?;
5091        let catalog_json = DdlOp::encode_catalog(&catalog)?;
5092        let txn_id = self.alloc_txn_id()?;
5093        let commit_seq = {
5094            let mut wal = self.shared_wal.lock();
5095            let append: Result<u64> = (|| {
5096                for (table_id, op) in prelude {
5097                    wal.append(txn_id, table_id, op)?;
5098                }
5099                wal.append(
5100                    txn_id,
5101                    WAL_TABLE_ID,
5102                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
5103                )?;
5104                wal.append_commit(txn_id, epoch, &[])
5105            })();
5106            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5107        };
5108        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5109        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
5110        self.finish_durable_publish(epoch, epoch_guard, &receipt, checkpoint)
5111    }
5112
5113    /// A WAL commit is already durable. Publish the matching catalog in memory
5114    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
5115    /// while the live handle must never continue with pre-commit metadata.
5116    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
5117        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
5118        let version = catalog.security_version;
5119        let principal = self.principal.read().clone();
5120        let principal = if catalog.require_auth
5121            && principal
5122                .as_ref()
5123                .is_some_and(|principal| principal.user_id != 0)
5124        {
5125            principal.as_ref().and_then(|principal| {
5126                Self::resolve_bound_principal_from_catalog(&catalog, principal)
5127            })
5128        } else {
5129            principal
5130        };
5131        *self.catalog.write() = catalog;
5132        self.security_coordinator
5133            .version
5134            .store(version, Ordering::Release);
5135        self.auth_state
5136            .set_require_auth(self.catalog.read().require_auth);
5137        *self.principal.write() = principal.clone();
5138        self.auth_state.set_principal(principal);
5139        checkpoint
5140    }
5141
5142    fn finish_durable_publish(
5143        &self,
5144        epoch: Epoch,
5145        epoch_guard: &mut EpochGuard<'_>,
5146        receipt: &mongreldb_log::CommitReceipt,
5147        post_step: Result<()>,
5148    ) -> Result<()> {
5149        if let Err(error) = self.publish_committed(receipt, epoch) {
5150            // The commit marker is durable but runtime publication failed. The
5151            // epoch guard stays armed so the assigned ticket is abandoned (the
5152            // watermark skips it), and the live handle poisons exactly like any
5153            // other post-durable failure.
5154            self.poisoned.store(true, Ordering::Relaxed);
5155            self.lifecycle.poison();
5156            return Err(MongrelError::DurableCommit {
5157                epoch: epoch.0,
5158                message: error.to_string(),
5159            });
5160        }
5161        epoch_guard.disarm();
5162        match post_step {
5163            Ok(()) => Ok(()),
5164            Err(error) => {
5165                self.poisoned.store(true, Ordering::Relaxed);
5166                self.lifecycle.poison();
5167                Err(MongrelError::DurableCommit {
5168                    epoch: epoch.0,
5169                    message: error.to_string(),
5170                })
5171            }
5172        }
5173    }
5174
5175    /// Advance reader visibility for a committed epoch. Publication is gated on
5176    /// the commit log's receipt (spec §9.4, FND-004): visibility only ever
5177    /// covers commands the commit log acknowledged durable. The
5178    /// `commit.publish.before`/`commit.publish.after` fault hooks bracket the
5179    /// watermark advance (spec §9.6, FND-006).
5180    fn publish_committed(
5181        &self,
5182        receipt: &mongreldb_log::CommitReceipt,
5183        epoch: Epoch,
5184    ) -> Result<()> {
5185        debug_assert_eq!(
5186            receipt.log_position.index, epoch.0,
5187            "commit receipt position must match the published epoch"
5188        );
5189        mongreldb_fault::inject("commit.publish.before").map_err(crate::commit_log::fault_as_io)?;
5190        self.epoch.publish_in_order(epoch);
5191        mongreldb_fault::inject("commit.publish.after").map_err(crate::commit_log::fault_as_io)?;
5192        Ok(())
5193    }
5194
5195    /// Wait for a commit marker to reach stable storage and return the commit
5196    /// log's irrevocable receipt (spec §9.4, FND-004). A failed append/fsync
5197    /// acknowledgement is ambiguous, so poison the live handle and preserve
5198    /// the assigned epoch in a structured unknown-outcome error.
5199    ///
5200    /// Used by the DDL/maintenance commit paths, which do not pre-assign a
5201    /// commit timestamp: the receipt's `commit_ts` is allocated from the
5202    /// node's HLC clock at seal time.
5203    fn await_durable_commit(
5204        &self,
5205        txn_id: u64,
5206        commit_seq: u64,
5207        epoch: Epoch,
5208    ) -> Result<mongreldb_log::CommitReceipt> {
5209        match self
5210            .standalone_commit_log
5211            .seal_transaction(txn_id, epoch, commit_seq, None)
5212        {
5213            Ok(receipt) => {
5214                self.record_commit_ts(epoch, receipt.commit_ts);
5215                Ok(receipt)
5216            }
5217            Err(error) => {
5218                self.poisoned.store(true, Ordering::Relaxed);
5219                self.lifecycle.poison();
5220                Err(MongrelError::CommitOutcomeUnknown {
5221                    epoch: epoch.0,
5222                    message: error.to_string(),
5223                })
5224            }
5225        }
5226    }
5227
5228    /// [`Self::await_durable_commit`] for the transaction commit sequencer,
5229    /// which assigned `commit_ts` under the sequencer lock (S1B-004 step 5,
5230    /// spec §8.2). The receipt carries that exact timestamp, matching the
5231    /// durable `Op::CommitTimestamp` ledger record written at append.
5232    fn await_durable_commit_with_ts(
5233        &self,
5234        txn_id: u64,
5235        commit_seq: u64,
5236        epoch: Epoch,
5237        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5238    ) -> Result<mongreldb_log::CommitReceipt> {
5239        match self.standalone_commit_log.seal_transaction(
5240            txn_id,
5241            epoch,
5242            commit_seq,
5243            Some(commit_ts),
5244        ) {
5245            Ok(receipt) => {
5246                self.record_commit_ts(epoch, receipt.commit_ts);
5247                Ok(receipt)
5248            }
5249            Err(error) => {
5250                self.poisoned.store(true, Ordering::Relaxed);
5251                self.lifecycle.poison();
5252                Err(MongrelError::CommitOutcomeUnknown {
5253                    epoch: epoch.0,
5254                    message: error.to_string(),
5255                })
5256            }
5257        }
5258    }
5259
5260    /// Record one durable commit's receipt timestamp in the per-open ledger
5261    /// (bounded to the newest [`COMMIT_TS_LEDGER_CAP`] epochs). Called by the
5262    /// durability funnels once the commit log has issued the irrevocable
5263    /// receipt.
5264    fn record_commit_ts(&self, epoch: Epoch, commit_ts: mongreldb_types::hlc::HlcTimestamp) {
5265        let mut ledger = self.commit_ts_ledger.lock();
5266        ledger.insert(epoch.0, commit_ts);
5267        while ledger.len() > COMMIT_TS_LEDGER_CAP {
5268            ledger.pop_first();
5269        }
5270    }
5271
5272    /// The commit timestamp of a durable commit, by epoch — the literal
5273    /// write receipt behind the server's read-your-writes token (spec §8.2).
5274    ///
5275    /// Returns `Some` for commits sealed within this open (the exact receipt
5276    /// `HlcTimestamp`) and for commits recovered from the durable
5277    /// `Op::CommitTimestamp` WAL ledger at open; the latter reconstruct the
5278    /// physical component only, with `logical` and `node_tiebreaker` as 0 per
5279    /// the ledger byte format. Only the newest [`COMMIT_TS_LEDGER_CAP`]
5280    /// epochs are retained, and epochs sealed through `CommitLog::propose`
5281    /// (catalog-command proposals) are not recorded here within a live open;
5282    /// both miss shapes return `None`, and callers fall back to a fresh-begin
5283    /// HLC, which the single clock authority orders after every commit it has
5284    /// already issued.
5285    pub fn commit_ts_for_epoch(&self, epoch: Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp> {
5286        self.commit_ts_ledger.lock().get(&epoch.0).copied()
5287    }
5288
5289    /// Newest retained commit epoch whose HLC timestamp is not after `timestamp`.
5290    ///
5291    /// The mapping is bounded by [`COMMIT_TS_LEDGER_CAP`]. Callers needing an
5292    /// older historical point must treat `None` as unavailable, never guess an
5293    /// epoch.
5294    pub fn epoch_at_or_before_commit_ts(
5295        &self,
5296        timestamp: mongreldb_types::hlc::HlcTimestamp,
5297    ) -> Option<Epoch> {
5298        self.commit_ts_ledger
5299            .lock()
5300            .iter()
5301            .rev()
5302            .find_map(|(epoch, commit_ts)| (*commit_ts <= timestamp).then_some(Epoch(*epoch)))
5303    }
5304
5305    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
5306        self.poisoned.store(true, Ordering::Relaxed);
5307        self.lifecycle.poison();
5308        MongrelError::CommitOutcomeUnknown {
5309            epoch: epoch.0,
5310            message: error.to_string(),
5311        }
5312    }
5313
5314    /// Persist a complete validated RLS/masking catalog through the WAL.
5315    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
5316        self.set_security_catalog_as_with_epoch(security, None)
5317            .map(|_| ())
5318    }
5319
5320    /// Persist security policy changes on behalf of an explicit request principal.
5321    pub fn set_security_catalog_as(
5322        &self,
5323        security: crate::security::SecurityCatalog,
5324        principal: Option<&crate::auth::Principal>,
5325    ) -> Result<()> {
5326        self.set_security_catalog_as_with_epoch(security, principal)
5327            .map(|_| ())
5328    }
5329
5330    /// Persist security policy changes and return the exact publication epoch.
5331    pub fn set_security_catalog_as_with_epoch(
5332        &self,
5333        security: crate::security::SecurityCatalog,
5334        principal: Option<&crate::auth::Principal>,
5335    ) -> Result<Epoch> {
5336        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
5337    }
5338
5339    /// Persist security policy changes, entering the commit fence immediately
5340    /// before the first WAL record can become visible to recovery.
5341    pub fn set_security_catalog_as_with_epoch_controlled<F>(
5342        &self,
5343        security: crate::security::SecurityCatalog,
5344        principal: Option<&crate::auth::Principal>,
5345        mut before_commit: F,
5346    ) -> Result<Epoch>
5347    where
5348        F: FnMut() -> Result<()>,
5349    {
5350        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
5351    }
5352
5353    fn set_security_catalog_as_with_epoch_inner(
5354        &self,
5355        security: crate::security::SecurityCatalog,
5356        principal: Option<&crate::auth::Principal>,
5357        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
5358    ) -> Result<Epoch> {
5359        use crate::wal::DdlOp;
5360        use std::sync::atomic::Ordering;
5361
5362        // S1F-001: the mutation is a versioned catalog command; its required
5363        // permission is checked against the caller principal first (identical
5364        // to the legacy hardcoded Admin gate).
5365        let command = crate::catalog_cmds::CatalogCommand::SetSecurityCatalog {
5366            security: security.clone(),
5367        };
5368        self.require_for(
5369            principal,
5370            &crate::catalog_cmds::required_permission(&command),
5371        )?;
5372        if self.poisoned.load(Ordering::Relaxed) {
5373            return Err(MongrelError::Other(
5374                "database poisoned by fsync error".into(),
5375            ));
5376        }
5377        // S1A-004: admit the security-catalog replacement as one core
5378        // operation.
5379        let _operation = self.admit_operation()?;
5380        let _ddl = self.ddl_lock.lock();
5381        // DDL serializes first; write-path order after that is security gate ->
5382        // commit lock -> shared WAL.
5383        let _security_write = self.security_write()?;
5384        self.require_for(
5385            principal,
5386            &crate::catalog_cmds::required_permission(&command),
5387        )?;
5388        let mut next_catalog = self.catalog.read().clone();
5389        validate_security_catalog(&next_catalog, &security)?;
5390        let payload = DdlOp::encode_security(&security)?;
5391        let _commit = self.commit_lock.lock();
5392        let epoch = self.epoch.bump_assigned();
5393        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5394        let txn_id = self.alloc_txn_id()?;
5395        self.apply_catalog_command_to(&mut next_catalog, command)?;
5396        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
5397        let commit_seq = {
5398            let mut wal = self.shared_wal.lock();
5399            if let Some(before_commit) = before_commit {
5400                before_commit()?;
5401            }
5402            let append: Result<u64> = (|| {
5403                wal.append(
5404                    txn_id,
5405                    WAL_TABLE_ID,
5406                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
5407                        security_json: payload,
5408                    }),
5409                )?;
5410                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
5411                wal.append_commit(txn_id, epoch, &[])
5412            })();
5413            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5414        };
5415        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5416        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
5417        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
5418        Ok(epoch)
5419    }
5420
5421    pub fn require_for(
5422        &self,
5423        principal: Option<&crate::auth::Principal>,
5424        permission: &crate::auth::Permission,
5425    ) -> Result<()> {
5426        let Some(principal) = principal else {
5427            return self.require(permission);
5428        };
5429        let resolved;
5430        let principal = if principal.user_id != 0 {
5431            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5432                .ok_or(MongrelError::AuthRequired)?;
5433            &resolved
5434        } else {
5435            principal
5436        };
5437        #[cfg(test)]
5438        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5439        if principal.has_permission(permission) {
5440            Ok(())
5441        } else {
5442            Err(MongrelError::PermissionDenied {
5443                required: permission.clone(),
5444                principal: principal.username.clone(),
5445            })
5446        }
5447    }
5448
5449    /// Recheck the exact operation principal while the caller holds the
5450    /// security gate. This deliberately performs no refresh or nested gate
5451    /// acquisition.
5452    fn require_exact_principal_current(
5453        &self,
5454        principal: Option<&crate::auth::Principal>,
5455        permission: &crate::auth::Permission,
5456    ) -> Result<()> {
5457        let catalog = self.catalog.read();
5458        if !catalog.require_auth {
5459            return Ok(());
5460        }
5461        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
5462        let current = if supplied.user_id == 0 {
5463            supplied.clone()
5464        } else {
5465            Self::resolve_bound_principal_from_catalog(&catalog, supplied)
5466                .ok_or(MongrelError::AuthRequired)?
5467        };
5468        if current.has_permission(permission) {
5469            Ok(())
5470        } else {
5471            Err(MongrelError::PermissionDenied {
5472                required: permission.clone(),
5473                principal: current.username,
5474            })
5475        }
5476    }
5477
5478    pub(crate) fn with_exact_principal_current<T, F>(
5479        &self,
5480        principal: Option<&crate::auth::Principal>,
5481        permission: &crate::auth::Permission,
5482        operation: F,
5483    ) -> Result<T>
5484    where
5485        F: FnOnce() -> Result<T>,
5486    {
5487        let _security = self.security_coordinator.gate.read();
5488        self.require_exact_principal_current(principal, permission)?;
5489        operation()
5490    }
5491
5492    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
5493        self.principal.read().clone()
5494    }
5495
5496    #[cfg(test)]
5497    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
5498        *self.principal.write() = principal.clone();
5499        self.auth_state.set_principal(principal);
5500    }
5501
5502    pub fn require_columns_for(
5503        &self,
5504        table: &str,
5505        operation: crate::auth::ColumnOperation,
5506        column_ids: &[u16],
5507        principal: Option<&crate::auth::Principal>,
5508    ) -> Result<()> {
5509        if principal.is_none() && !self.auth_state.require_auth() {
5510            return Ok(());
5511        }
5512        let cached = self.principal.read().clone();
5513        let principal = principal.or(cached.as_ref());
5514        let Some(principal) = principal else {
5515            let permission = match operation {
5516                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5517                    table: table.to_string(),
5518                },
5519                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5520                    table: table.to_string(),
5521                },
5522                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5523                    table: table.to_string(),
5524                },
5525            };
5526            return self.require(&permission);
5527        };
5528        let catalog = self.catalog.read();
5529        let resolved;
5530        let principal = if principal.user_id != 0 {
5531            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
5532                .ok_or(MongrelError::AuthRequired)?;
5533            &resolved
5534        } else {
5535            principal
5536        };
5537        let schema = &catalog
5538            .live(table)
5539            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5540            .schema;
5541        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
5542    }
5543
5544    fn require_columns_for_principal(
5545        table: &str,
5546        schema: &Schema,
5547        operation: crate::auth::ColumnOperation,
5548        column_ids: &[u16],
5549        principal: &crate::auth::Principal,
5550    ) -> Result<()> {
5551        #[cfg(test)]
5552        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5553        match principal.column_access(table, operation) {
5554            crate::auth::ColumnAccess::All => Ok(()),
5555            crate::auth::ColumnAccess::Columns(allowed) => {
5556                let denied = column_ids.iter().find_map(|column_id| {
5557                    schema
5558                        .columns
5559                        .iter()
5560                        .find(|column| column.id == *column_id)
5561                        .filter(|column| !allowed.contains(&column.name))
5562                });
5563                if denied.is_none() {
5564                    Ok(())
5565                } else {
5566                    Err(MongrelError::PermissionDenied {
5567                        required: match operation {
5568                            crate::auth::ColumnOperation::Select => {
5569                                crate::auth::Permission::SelectColumns {
5570                                    table: table.to_string(),
5571                                    columns: denied
5572                                        .into_iter()
5573                                        .map(|column| column.name.clone())
5574                                        .collect(),
5575                                }
5576                            }
5577                            crate::auth::ColumnOperation::Insert => {
5578                                crate::auth::Permission::InsertColumns {
5579                                    table: table.to_string(),
5580                                    columns: denied
5581                                        .into_iter()
5582                                        .map(|column| column.name.clone())
5583                                        .collect(),
5584                                }
5585                            }
5586                            crate::auth::ColumnOperation::Update => {
5587                                crate::auth::Permission::UpdateColumns {
5588                                    table: table.to_string(),
5589                                    columns: denied
5590                                        .into_iter()
5591                                        .map(|column| column.name.clone())
5592                                        .collect(),
5593                                }
5594                            }
5595                        },
5596                        principal: principal.username.clone(),
5597                    })
5598                }
5599            }
5600            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5601                required: match operation {
5602                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5603                        table: table.to_string(),
5604                    },
5605                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5606                        table: table.to_string(),
5607                    },
5608                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5609                        table: table.to_string(),
5610                    },
5611                },
5612                principal: principal.username.clone(),
5613            }),
5614        }
5615    }
5616
5617    pub fn select_column_ids_for(
5618        &self,
5619        table: &str,
5620        principal: Option<&crate::auth::Principal>,
5621    ) -> Result<Vec<u16>> {
5622        let catalog = self.catalog.read();
5623        let columns = catalog
5624            .live(table)
5625            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5626            .schema
5627            .columns
5628            .iter()
5629            .map(|column| (column.id, column.name.clone()))
5630            .collect::<Vec<_>>();
5631        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
5632        drop(catalog);
5633        let Some(principal) = principal.as_ref() else {
5634            self.require(&crate::auth::Permission::Select {
5635                table: table.to_string(),
5636            })?;
5637            return Ok(columns.iter().map(|(id, _)| *id).collect());
5638        };
5639        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
5640            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
5641            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
5642                .iter()
5643                .filter(|(_, name)| allowed.contains(name))
5644                .map(|(id, _)| *id)
5645                .collect()),
5646            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5647                required: crate::auth::Permission::Select {
5648                    table: table.to_string(),
5649                },
5650                principal: principal.username.clone(),
5651            }),
5652        }
5653    }
5654
5655    pub fn secure_rows_for(
5656        &self,
5657        table: &str,
5658        rows: Vec<crate::memtable::Row>,
5659        principal: Option<&crate::auth::Principal>,
5660    ) -> Result<Vec<crate::memtable::Row>> {
5661        self.secure_rows_for_with_context(table, rows, principal, None)
5662    }
5663
5664    pub fn secure_rows_for_with_context(
5665        &self,
5666        table: &str,
5667        rows: Vec<crate::memtable::Row>,
5668        principal: Option<&crate::auth::Principal>,
5669        context: Option<&crate::query::AiExecutionContext>,
5670    ) -> Result<Vec<crate::memtable::Row>> {
5671        let (security, principal) = {
5672            let catalog = self.catalog.read();
5673            (
5674                catalog.security.clone(),
5675                self.principal_for_authorized_read(&catalog, principal, false)?,
5676            )
5677        };
5678        if !security.table_has_security(table) {
5679            return Ok(rows);
5680        }
5681        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5682        let mut output = Vec::new();
5683        for mut row in rows {
5684            if let Some(context) = context {
5685                context.consume(1)?;
5686            }
5687            if security.row_allowed(
5688                table,
5689                crate::security::PolicyCommand::Select,
5690                &row,
5691                principal,
5692                false,
5693            ) {
5694                security.apply_masks(table, &mut row, principal);
5695                output.push(row);
5696            }
5697        }
5698        Ok(output)
5699    }
5700
5701    /// Apply column masks to already RLS-authorized scored hits without a
5702    /// second row gather or policy evaluation.
5703    pub fn mask_search_hits_for(
5704        &self,
5705        table: &str,
5706        hits: &mut [crate::query::SearchHit],
5707        principal: Option<&crate::auth::Principal>,
5708    ) -> Result<()> {
5709        let (security, principal) = {
5710            let catalog = self.catalog.read();
5711            (
5712                catalog.security.clone(),
5713                self.principal_for_authorized_read(&catalog, principal, false)?,
5714            )
5715        };
5716        if !security.table_has_security(table) {
5717            return Ok(());
5718        }
5719        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5720        for hit in hits {
5721            security.apply_masks_to_cells(table, &mut hit.cells, principal);
5722        }
5723        Ok(())
5724    }
5725
5726    /// Apply masks to rows already admitted by candidate-aware RLS.
5727    pub fn mask_rows_for(
5728        &self,
5729        table: &str,
5730        rows: &mut [crate::memtable::Row],
5731        principal: Option<&crate::auth::Principal>,
5732    ) -> Result<()> {
5733        let (security, principal) = {
5734            let catalog = self.catalog.read();
5735            (
5736                catalog.security.clone(),
5737                self.principal_for_authorized_read(&catalog, principal, false)?,
5738            )
5739        };
5740        if !security.table_has_security(table) {
5741            return Ok(());
5742        }
5743        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5744        for row in rows {
5745            security.apply_masks(table, row, principal);
5746        }
5747        Ok(())
5748    }
5749
5750    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
5751    pub fn authorized_candidate_ids_for(
5752        &self,
5753        table: &str,
5754        principal: Option<&crate::auth::Principal>,
5755    ) -> Result<Option<std::collections::HashSet<RowId>>> {
5756        Ok(self
5757            .authorized_read_snapshot(table, principal)?
5758            .allowed_row_ids)
5759    }
5760
5761    fn allowed_row_ids_locked(
5762        &self,
5763        table_name: &str,
5764        table: &Table,
5765        table_snapshot: Snapshot,
5766        security_state: (&crate::security::SecurityCatalog, u64),
5767        principal: Option<&crate::auth::Principal>,
5768        context: Option<&crate::query::AiExecutionContext>,
5769    ) -> Result<Option<Arc<HashSet<RowId>>>> {
5770        let (security, security_version) = security_state;
5771        if !security.rls_enabled(table_name) {
5772            return Ok(None);
5773        }
5774        let authorization_started = std::time::Instant::now();
5775        let principal = principal.ok_or(MongrelError::AuthRequired)?;
5776        let mut roles = principal.roles.clone();
5777        roles.sort_unstable();
5778        let principal_key = format!(
5779            "{}:{}:{}:{}:{roles:?}",
5780            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
5781        );
5782        let cache_key = (
5783            table_name.to_string(),
5784            table.data_generation(),
5785            security_version,
5786            principal_key,
5787        );
5788        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
5789            crate::trace::QueryTrace::record(|trace| {
5790                trace.rls_cache_hit = true;
5791                trace.authorization_nanos = trace
5792                    .authorization_nanos
5793                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5794            });
5795            return Ok(Some(allowed));
5796        }
5797        if let Some(context) = context {
5798            context.checkpoint()?;
5799        }
5800        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
5801        let started = std::time::Instant::now();
5802        let rows = table.visible_rows(table_snapshot)?;
5803        let rows_evaluated = rows.len() as u64;
5804        let mut allowed = HashSet::new();
5805        for chunk in rows.chunks(256) {
5806            if let Some(context) = context {
5807                context.consume(chunk.len())?;
5808            }
5809            allowed.extend(chunk.iter().filter_map(|row| {
5810                security
5811                    .row_allowed(
5812                        table_name,
5813                        crate::security::PolicyCommand::Select,
5814                        row,
5815                        principal,
5816                        false,
5817                    )
5818                    .then_some(row.row_id)
5819            }));
5820        }
5821        let allowed = Arc::new(allowed);
5822        let mut cache = self.rls_cache.lock();
5823        cache.build_nanos = cache
5824            .build_nanos
5825            .saturating_add(started.elapsed().as_nanos() as u64);
5826        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
5827        cache.insert(cache_key, Arc::clone(&allowed));
5828        crate::trace::QueryTrace::record(|trace| {
5829            trace.rls_rows_evaluated = trace
5830                .rls_rows_evaluated
5831                .saturating_add(rows_evaluated as usize);
5832            trace.authorization_nanos = trace
5833                .authorization_nanos
5834                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5835        });
5836        Ok(Some(allowed))
5837    }
5838
5839    fn principal_for_authorized_read(
5840        &self,
5841        catalog: &Catalog,
5842        principal: Option<&crate::auth::Principal>,
5843        catalog_bound: bool,
5844    ) -> Result<Option<crate::auth::Principal>> {
5845        let principal = principal.cloned().or_else(|| self.principal.read().clone());
5846        let Some(principal) = principal else {
5847            return Ok(None);
5848        };
5849        if catalog_bound || principal.user_id != 0 {
5850            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
5851                .map(Some)
5852                .ok_or(MongrelError::AuthRequired);
5853        }
5854        Ok(Some(principal))
5855    }
5856
5857    /// Run authorization, candidate generation, ranking, and materialization
5858    /// while holding one table generation. Security changes cause a bounded
5859    /// retry before any result is published.
5860    pub fn with_authorized_read<T, F>(
5861        &self,
5862        table_name: &str,
5863        principal: Option<&crate::auth::Principal>,
5864        catalog_bound: bool,
5865        read: F,
5866    ) -> Result<T>
5867    where
5868        F: FnMut(
5869            &mut Table,
5870            Snapshot,
5871            Option<&HashSet<RowId>>,
5872            Option<&crate::auth::Principal>,
5873        ) -> Result<T>,
5874    {
5875        self.with_authorized_read_context(
5876            table_name,
5877            principal,
5878            catalog_bound,
5879            None,
5880            None,
5881            None,
5882            read,
5883        )
5884    }
5885
5886    #[allow(clippy::too_many_arguments)]
5887    pub fn with_authorized_read_context<T, F>(
5888        &self,
5889        table_name: &str,
5890        principal: Option<&crate::auth::Principal>,
5891        catalog_bound: bool,
5892        authorization: Option<&ReadAuthorization>,
5893        context: Option<&crate::query::AiExecutionContext>,
5894        snapshot_override: Option<Snapshot>,
5895        read: F,
5896    ) -> Result<T>
5897    where
5898        F: FnMut(
5899            &mut Table,
5900            Snapshot,
5901            Option<&HashSet<RowId>>,
5902            Option<&crate::auth::Principal>,
5903        ) -> Result<T>,
5904    {
5905        self.with_authorized_read_context_stamped(
5906            table_name,
5907            principal,
5908            catalog_bound,
5909            authorization,
5910            context,
5911            snapshot_override,
5912            read,
5913        )
5914        .map(|(result, _)| result)
5915    }
5916
5917    #[allow(clippy::too_many_arguments)]
5918    pub fn with_authorized_read_context_stamped<T, F>(
5919        &self,
5920        table_name: &str,
5921        principal: Option<&crate::auth::Principal>,
5922        catalog_bound: bool,
5923        authorization: Option<&ReadAuthorization>,
5924        context: Option<&crate::query::AiExecutionContext>,
5925        snapshot_override: Option<Snapshot>,
5926        mut read: F,
5927    ) -> Result<(T, AuthorizedReadStamp)>
5928    where
5929        F: FnMut(
5930            &mut Table,
5931            Snapshot,
5932            Option<&HashSet<RowId>>,
5933            Option<&crate::auth::Principal>,
5934        ) -> Result<T>,
5935    {
5936        if principal.is_none() && self.principal.read().is_some() {
5937            self.refresh_principal()?;
5938        }
5939        const RETRIES: usize = 3;
5940        let handle = self.table(table_name)?;
5941        for attempt in 0..RETRIES {
5942            crate::trace::QueryTrace::record(|trace| {
5943                trace.authorization_retries = attempt;
5944            });
5945            let (security, security_version, effective_principal) = {
5946                let catalog = self.catalog.read();
5947                (
5948                    catalog.security.clone(),
5949                    catalog.security_version,
5950                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
5951                )
5952            };
5953            if let Some(authorization) = authorization {
5954                for permission in &authorization.permissions {
5955                    self.require_for(effective_principal.as_ref(), permission)?;
5956                }
5957                self.require_columns_for(
5958                    table_name,
5959                    authorization.operation,
5960                    &authorization.columns,
5961                    effective_principal.as_ref(),
5962                )?;
5963            }
5964            let result = {
5965                let mut table = lock_table_with_context(&handle, context)?;
5966                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
5967                let allowed = self.allowed_row_ids_locked(
5968                    table_name,
5969                    &table,
5970                    snapshot,
5971                    (&security, security_version),
5972                    effective_principal.as_ref(),
5973                    context,
5974                )?;
5975                let stamp = AuthorizedReadStamp {
5976                    table_id: table.table_id(),
5977                    schema_id: table.schema().schema_id,
5978                    data_generation: table.data_generation(),
5979                    security_version,
5980                    snapshot,
5981                };
5982                let result = read(
5983                    &mut table,
5984                    snapshot,
5985                    allowed.as_deref(),
5986                    effective_principal.as_ref(),
5987                )?;
5988                (result, stamp)
5989            };
5990            if let Some(context) = context {
5991                context.checkpoint()?;
5992            }
5993            if self.catalog.read().security_version == security_version {
5994                return Ok(result);
5995            }
5996            if attempt + 1 == RETRIES {
5997                return Err(MongrelError::Conflict(
5998                    "security policy changed during scored read".into(),
5999                ));
6000            }
6001        }
6002        Err(MongrelError::Conflict(
6003            "authorization retry loop exhausted".into(),
6004        ))
6005    }
6006
6007    fn with_authorized_aggregate_table<T, F>(
6008        &self,
6009        table_name: &str,
6010        columns: &[u16],
6011        principal: Option<&crate::auth::Principal>,
6012        catalog_bound: bool,
6013        allow_table_security: bool,
6014        mut aggregate: F,
6015    ) -> Result<T>
6016    where
6017        F: FnMut(
6018            &mut Table,
6019            Option<&crate::security::CandidateAuthorization<'_>>,
6020            Option<&crate::auth::Principal>,
6021            u64,
6022        ) -> Result<T>,
6023    {
6024        if principal.is_none() && self.principal.read().is_some() {
6025            self.refresh_principal()?;
6026        }
6027        const RETRIES: usize = 3;
6028        let handle = self.table(table_name)?;
6029        for attempt in 0..RETRIES {
6030            let (security, security_version, effective_principal) = {
6031                let catalog = self.catalog.read();
6032                (
6033                    catalog.security.clone(),
6034                    catalog.security_version,
6035                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6036                )
6037            };
6038            self.require_columns_for(
6039                table_name,
6040                crate::auth::ColumnOperation::Select,
6041                columns,
6042                effective_principal.as_ref(),
6043            )?;
6044            if !allow_table_security && security.table_has_security(table_name) {
6045                return Err(MongrelError::InvalidArgument(
6046                    "incremental aggregate is unsupported while RLS or column masks are active"
6047                        .into(),
6048                ));
6049            }
6050            let result = {
6051                let mut table = handle.lock();
6052                let authorization = if security.rls_enabled(table_name) {
6053                    Some(crate::security::CandidateAuthorization {
6054                        table: table_name,
6055                        security: &security,
6056                        principal: effective_principal
6057                            .as_ref()
6058                            .ok_or(MongrelError::AuthRequired)?,
6059                    })
6060                } else {
6061                    None
6062                };
6063                aggregate(
6064                    &mut table,
6065                    authorization.as_ref(),
6066                    effective_principal.as_ref(),
6067                    security_version,
6068                )?
6069            };
6070            if self.catalog.read().security_version == security_version {
6071                return Ok(result);
6072            }
6073            if attempt + 1 == RETRIES {
6074                return Err(MongrelError::Conflict(
6075                    "security policy changed during aggregate read".into(),
6076                ));
6077            }
6078        }
6079        Err(MongrelError::Conflict(
6080            "aggregate authorization retry loop exhausted".into(),
6081        ))
6082    }
6083
6084    /// Scored-read authorization that evaluates RLS only for approximate
6085    /// candidates. This avoids a full-table policy scan on cache misses while
6086    /// preserving one table generation and security-version retry.
6087    pub fn with_authorized_scored_read_context<T, F>(
6088        &self,
6089        table_name: &str,
6090        principal: Option<&crate::auth::Principal>,
6091        catalog_bound: bool,
6092        authorization: Option<&ReadAuthorization>,
6093        context: Option<&crate::query::AiExecutionContext>,
6094        mut read: F,
6095    ) -> Result<T>
6096    where
6097        F: FnMut(
6098            &mut Table,
6099            Snapshot,
6100            Option<&crate::security::CandidateAuthorization<'_>>,
6101            Option<&crate::auth::Principal>,
6102        ) -> Result<T>,
6103    {
6104        self.with_authorized_scored_read_context_at(
6105            table_name,
6106            principal,
6107            catalog_bound,
6108            authorization,
6109            context,
6110            None,
6111            |table, snapshot, authorization, principal| {
6112                let mut table = table.clone();
6113                read(&mut table, snapshot, authorization, principal)
6114            },
6115        )
6116    }
6117
6118    #[allow(clippy::too_many_arguments)]
6119    pub fn with_authorized_scored_read_context_at<T, F>(
6120        &self,
6121        table_name: &str,
6122        principal: Option<&crate::auth::Principal>,
6123        catalog_bound: bool,
6124        authorization: Option<&ReadAuthorization>,
6125        context: Option<&crate::query::AiExecutionContext>,
6126        snapshot_override: Option<Snapshot>,
6127        read: F,
6128    ) -> Result<T>
6129    where
6130        F: FnMut(
6131            &Table,
6132            Snapshot,
6133            Option<&crate::security::CandidateAuthorization<'_>>,
6134            Option<&crate::auth::Principal>,
6135        ) -> Result<T>,
6136    {
6137        self.with_authorized_scored_read_context_at_stamped(
6138            table_name,
6139            principal,
6140            catalog_bound,
6141            authorization,
6142            context,
6143            snapshot_override,
6144            read,
6145        )
6146        .map(|(result, _)| result)
6147    }
6148
6149    #[allow(clippy::too_many_arguments)]
6150    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
6151        &self,
6152        table_name: &str,
6153        principal: Option<&crate::auth::Principal>,
6154        catalog_bound: bool,
6155        authorization: Option<&ReadAuthorization>,
6156        context: Option<&crate::query::AiExecutionContext>,
6157        snapshot_override: Option<Snapshot>,
6158        mut read: F,
6159    ) -> Result<(T, AuthorizedReadStamp)>
6160    where
6161        F: FnMut(
6162            &Table,
6163            Snapshot,
6164            Option<&crate::security::CandidateAuthorization<'_>>,
6165            Option<&crate::auth::Principal>,
6166        ) -> Result<T>,
6167    {
6168        if principal.is_none() && self.principal.read().is_some() {
6169            self.refresh_principal()?;
6170        }
6171        const RETRIES: usize = 3;
6172        let handle = self.table(table_name)?;
6173        for attempt in 0..RETRIES {
6174            if let Some(context) = context {
6175                context.checkpoint()?;
6176            }
6177            crate::trace::QueryTrace::record(|trace| {
6178                trace.authorization_retries = attempt;
6179            });
6180            let (security, security_version, effective_principal) = {
6181                let catalog = self.catalog.read();
6182                (
6183                    catalog.security.clone(),
6184                    catalog.security_version,
6185                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6186                )
6187            };
6188            if let Some(authorization) = authorization {
6189                for permission in &authorization.permissions {
6190                    self.require_for(effective_principal.as_ref(), permission)?;
6191                }
6192                self.require_columns_for(
6193                    table_name,
6194                    authorization.operation,
6195                    &authorization.columns,
6196                    effective_principal.as_ref(),
6197                )?;
6198            }
6199            let result = {
6200                let (table, snapshot, _snapshot_guard, _run_pins) =
6201                    self.scored_read_generation(&handle, context, snapshot_override)?;
6202                let candidate_authorization = if security.rls_enabled(table_name) {
6203                    Some(crate::security::CandidateAuthorization {
6204                        table: table_name,
6205                        security: &security,
6206                        principal: effective_principal
6207                            .as_ref()
6208                            .ok_or(MongrelError::AuthRequired)?,
6209                    })
6210                } else {
6211                    None
6212                };
6213                let stamp = AuthorizedReadStamp {
6214                    table_id: table.table_id(),
6215                    schema_id: table.schema().schema_id,
6216                    data_generation: table.data_generation(),
6217                    security_version,
6218                    snapshot,
6219                };
6220                let result = read(
6221                    table.as_ref(),
6222                    snapshot,
6223                    candidate_authorization.as_ref(),
6224                    effective_principal.as_ref(),
6225                )?;
6226                (result, stamp)
6227            };
6228            if let Some(context) = context {
6229                context.checkpoint()?;
6230            }
6231            if self.catalog.read().security_version == security_version {
6232                return Ok(result);
6233            }
6234            if attempt + 1 == RETRIES {
6235                return Err(MongrelError::Conflict(
6236                    "security policy changed during scored read".into(),
6237                ));
6238            }
6239        }
6240        Err(MongrelError::Conflict(
6241            "scored-read authorization retry loop exhausted".into(),
6242        ))
6243    }
6244
6245    fn scored_read_generation(
6246        &self,
6247        handle: &TableHandle,
6248        context: Option<&crate::query::AiExecutionContext>,
6249        snapshot_override: Option<Snapshot>,
6250    ) -> Result<(
6251        Arc<TableReadGeneration>,
6252        Snapshot,
6253        crate::retention::OwnedSnapshotGuard,
6254        RunPins,
6255    )> {
6256        let mut table = if let Some(context) = context {
6257            loop {
6258                context.checkpoint()?;
6259                let wait = context
6260                    .remaining_duration()
6261                    .unwrap_or(std::time::Duration::from_millis(5))
6262                    .min(std::time::Duration::from_millis(5));
6263                if let Some(table) = handle.try_lock_for(wait) {
6264                    break table;
6265                }
6266            }
6267        } else {
6268            handle.lock()
6269        };
6270        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
6271            self.snapshot_at_owned(snapshot.epoch)?
6272        } else {
6273            let snapshot = table.snapshot();
6274            let guard = self.snapshots.register_owned(snapshot.epoch);
6275            (snapshot, guard)
6276        };
6277        let table_id = table.table_id();
6278        let run_keys: Vec<_> = table
6279            .active_run_ids()
6280            .map(|run_id| (table_id, run_id))
6281            .collect();
6282        let generation = handle
6283            .generation_metrics
6284            .activate(table.clone_read_generation()?);
6285        let run_pins = self.pin_runs(&run_keys);
6286        Ok((generation, snapshot, snapshot_guard, run_pins))
6287    }
6288
6289    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
6290        let mut pins = self.backup_pins.lock();
6291        for run in runs {
6292            *pins.entry(*run).or_insert(0) += 1;
6293        }
6294        drop(pins);
6295        RunPins {
6296            pins: Arc::clone(&self.backup_pins),
6297            runs: runs.to_vec(),
6298        }
6299    }
6300
6301    /// Execute a native conjunctive read with the database principal's row
6302    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
6303    /// policy-unaware; language bindings must use this boundary for reads.
6304    pub fn query_for_current_principal(
6305        &self,
6306        table_name: &str,
6307        query: &crate::query::Query,
6308        projection: Option<&[u16]>,
6309    ) -> Result<Vec<crate::memtable::Row>> {
6310        let condition_columns = crate::query::condition_columns(&query.conditions);
6311        let catalog_bound = self
6312            .principal
6313            .read()
6314            .as_ref()
6315            .is_some_and(|principal| principal.user_id != 0);
6316        self.with_authorized_read(
6317            table_name,
6318            None,
6319            catalog_bound,
6320            |table, snapshot, allowed, principal| {
6321                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6322                self.require_columns_for(
6323                    table_name,
6324                    crate::auth::ColumnOperation::Select,
6325                    &condition_columns,
6326                    principal,
6327                )?;
6328                if let Some(projection) = projection {
6329                    self.require_columns_for(
6330                        table_name,
6331                        crate::auth::ColumnOperation::Select,
6332                        projection,
6333                        principal,
6334                    )?;
6335                }
6336                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
6337                let projection =
6338                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6339                for row in &mut rows {
6340                    row.columns.retain(|column, _| {
6341                        allowed_columns.contains(column)
6342                            && projection
6343                                .as_ref()
6344                                .is_none_or(|projection| projection.contains(column))
6345                    });
6346                }
6347                self.secure_rows_for(table_name, rows, principal)
6348            },
6349        )
6350    }
6351
6352    /// Execute a secured native read with cooperative cancellation across
6353    /// authorization, candidate generation, materialization, masking, and
6354    /// projection.
6355    pub fn query_for_current_principal_controlled(
6356        &self,
6357        table_name: &str,
6358        query: &crate::query::Query,
6359        projection: Option<&[u16]>,
6360        control: &crate::ExecutionControl,
6361    ) -> Result<Vec<crate::memtable::Row>> {
6362        let catalog_bound = self
6363            .principal
6364            .read()
6365            .as_ref()
6366            .is_some_and(|principal| principal.user_id != 0);
6367        self.query_for_principal_controlled(
6368            table_name,
6369            query,
6370            projection,
6371            None,
6372            catalog_bound,
6373            control,
6374        )
6375    }
6376
6377    /// Execute a secured native read as an explicitly validated principal.
6378    ///
6379    /// Protocol adapters use this after resolving a session-bound identity.
6380    pub fn query_as_principal_controlled(
6381        &self,
6382        table_name: &str,
6383        query: &crate::query::Query,
6384        projection: Option<&[u16]>,
6385        principal: Option<&crate::auth::Principal>,
6386        control: &crate::ExecutionControl,
6387    ) -> Result<Vec<crate::memtable::Row>> {
6388        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6389        self.query_for_principal_controlled(
6390            table_name,
6391            query,
6392            projection,
6393            principal,
6394            catalog_bound,
6395            control,
6396        )
6397    }
6398
6399    fn query_for_principal_controlled(
6400        &self,
6401        table_name: &str,
6402        query: &crate::query::Query,
6403        projection: Option<&[u16]>,
6404        principal: Option<&crate::auth::Principal>,
6405        catalog_bound: bool,
6406        control: &crate::ExecutionControl,
6407    ) -> Result<Vec<crate::memtable::Row>> {
6408        control.checkpoint()?;
6409        let context = crate::query::AiExecutionContext::with_control(
6410            control.clone(),
6411            usize::MAX,
6412            crate::query::MAX_FUSED_CANDIDATES,
6413        );
6414        let condition_columns = crate::query::condition_columns(&query.conditions);
6415        self.with_authorized_read_context(
6416            table_name,
6417            principal,
6418            catalog_bound,
6419            None,
6420            Some(&context),
6421            None,
6422            |table, snapshot, allowed, principal| {
6423                control.checkpoint()?;
6424                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6425                self.require_columns_for(
6426                    table_name,
6427                    crate::auth::ColumnOperation::Select,
6428                    &condition_columns,
6429                    principal,
6430                )?;
6431                if let Some(projection) = projection {
6432                    self.require_columns_for(
6433                        table_name,
6434                        crate::auth::ColumnOperation::Select,
6435                        projection,
6436                        principal,
6437                    )?;
6438                }
6439                let rows =
6440                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
6441                let projection =
6442                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6443                let mut projected = Vec::with_capacity(rows.len());
6444                for (index, mut row) in rows.into_iter().enumerate() {
6445                    if index & 255 == 0 {
6446                        control.checkpoint()?;
6447                    }
6448                    row.columns.retain(|column, _| {
6449                        allowed_columns.contains(column)
6450                            && projection
6451                                .as_ref()
6452                                .is_none_or(|projection| projection.contains(column))
6453                    });
6454                    projected.push(row);
6455                }
6456                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
6457            },
6458        )
6459    }
6460
6461    /// Reservoir aggregate with column grants, RLS, masks, and security-version
6462    /// retry applied at the database boundary.
6463    pub fn approx_aggregate_for_current_principal(
6464        &self,
6465        table_name: &str,
6466        conditions: &[crate::query::Condition],
6467        column: Option<u16>,
6468        agg: crate::engine::ApproxAgg,
6469        z: f64,
6470    ) -> Result<Option<crate::engine::ApproxResult>> {
6471        if !z.is_finite() || z <= 0.0 {
6472            return Err(MongrelError::InvalidArgument(
6473                "z must be finite and > 0".into(),
6474            ));
6475        }
6476        let mut columns = crate::query::condition_columns(conditions);
6477        columns.extend(column);
6478        columns.sort_unstable();
6479        columns.dedup();
6480        self.with_authorized_aggregate_table(
6481            table_name,
6482            &columns,
6483            None,
6484            true,
6485            true,
6486            |table, authorization, _, _| {
6487                table.approx_aggregate_with_candidate_authorization(
6488                    conditions,
6489                    column,
6490                    agg,
6491                    z,
6492                    authorization,
6493                )
6494            },
6495        )
6496    }
6497
6498    /// Incremental aggregate over an append-only table. Active RLS or masks are
6499    /// rejected because the table-global delta cache cannot safely represent a
6500    /// secured row universe.
6501    pub fn incremental_aggregate_for_current_principal(
6502        &self,
6503        table_name: &str,
6504        conditions: &[crate::query::Condition],
6505        column: Option<u16>,
6506        agg: crate::engine::NativeAgg,
6507    ) -> Result<crate::engine::IncrementalAggResult> {
6508        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
6509    }
6510
6511    /// Incremental aggregate using an explicit request principal. A
6512    /// catalog-bound principal is re-resolved on every retry so live grants,
6513    /// revocations, RLS, and masks cannot reuse a stale cache entry.
6514    pub fn incremental_aggregate_for_principal(
6515        &self,
6516        table_name: &str,
6517        conditions: &[crate::query::Condition],
6518        column: Option<u16>,
6519        agg: crate::engine::NativeAgg,
6520        principal: Option<&crate::auth::Principal>,
6521        catalog_bound: bool,
6522    ) -> Result<crate::engine::IncrementalAggResult> {
6523        let mut columns = crate::query::condition_columns(conditions);
6524        columns.extend(column);
6525        columns.sort_unstable();
6526        columns.dedup();
6527        self.with_authorized_aggregate_table(
6528            table_name,
6529            &columns,
6530            principal,
6531            catalog_bound,
6532            false,
6533            |table, _, principal, security_version| {
6534                let cache_key = incremental_aggregate_cache_key(
6535                    table_name,
6536                    conditions,
6537                    column,
6538                    agg,
6539                    principal,
6540                    security_version,
6541                );
6542                table.aggregate_incremental(cache_key, conditions, column, agg)
6543            },
6544        )
6545    }
6546
6547    /// Read one row with the database principal's row policy, column grants,
6548    /// and masks applied.
6549    pub fn get_for_current_principal(
6550        &self,
6551        table_name: &str,
6552        row_id: RowId,
6553    ) -> Result<Option<crate::memtable::Row>> {
6554        self.with_authorized_read(
6555            table_name,
6556            None,
6557            true,
6558            |table, snapshot, allowed, principal| {
6559                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6560                let Some(row) = table.get(row_id, snapshot) else {
6561                    return Ok(None);
6562                };
6563                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
6564                    return Ok(None);
6565                }
6566                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6567                if let Some(row) = rows.first_mut() {
6568                    row.columns
6569                        .retain(|column, _| allowed_columns.contains(column));
6570                }
6571                Ok(rows.pop())
6572            },
6573        )
6574    }
6575
6576    /// Run exact ANN reranking over only rows authorized for this database
6577    /// handle. The embedding column still requires normal column access.
6578    pub fn ann_rerank_for_current_principal(
6579        &self,
6580        table_name: &str,
6581        request: &crate::query::AnnRerankRequest,
6582    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6583        self.with_authorized_scored_read_context_at(
6584            table_name,
6585            None,
6586            true,
6587            Some(&ReadAuthorization {
6588                operation: crate::auth::ColumnOperation::Select,
6589                columns: vec![request.column_id],
6590                permissions: Vec::new(),
6591            }),
6592            None,
6593            None,
6594            |table, snapshot, authorization, principal| {
6595                self.require_columns_for(
6596                    table_name,
6597                    crate::auth::ColumnOperation::Select,
6598                    &[request.column_id],
6599                    principal,
6600                )?;
6601                table.ann_rerank_at_with_candidate_authorization_on_generation(
6602                    request,
6603                    snapshot,
6604                    authorization,
6605                    None,
6606                )
6607            },
6608        )
6609    }
6610
6611    /// Run a hybrid scored search over only rows authorized for this database
6612    /// handle. Applies retriever column grants, RLS, and masks to the returned
6613    /// hits.
6614    pub fn search_for_current_principal(
6615        &self,
6616        table_name: &str,
6617        request: &crate::query::SearchRequest,
6618    ) -> Result<Vec<crate::query::SearchHit>> {
6619        let principal = self.principal_snapshot();
6620        self.search_for_principal_with_context(table_name, request, principal.as_ref(), None)
6621    }
6622
6623    /// Run a hybrid scored search as an explicitly validated principal.
6624    ///
6625    /// Protocol adapters use this after validating their session-bound
6626    /// identity. RLS, column grants, masks, cancellation, deadlines, and work
6627    /// limits remain enforced inside the storage authorization boundary.
6628    pub fn search_for_principal_with_context(
6629        &self,
6630        table_name: &str,
6631        request: &crate::query::SearchRequest,
6632        principal: Option<&crate::auth::Principal>,
6633        context: Option<&crate::query::AiExecutionContext>,
6634    ) -> Result<Vec<crate::query::SearchHit>> {
6635        let mut columns = crate::query::condition_columns(&request.must);
6636        for named in &request.retrievers {
6637            columns.push(named.retriever.column_id());
6638        }
6639        if let Some(crate::query::Rerank::ExactVector {
6640            embedding_column, ..
6641        }) = &request.rerank
6642        {
6643            columns.push(*embedding_column);
6644        }
6645        if let Some(proj) = &request.projection {
6646            columns.extend(proj);
6647        }
6648        columns.sort_unstable();
6649        columns.dedup();
6650        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6651        self.with_authorized_scored_read_context_at(
6652            table_name,
6653            principal,
6654            catalog_bound,
6655            Some(&ReadAuthorization {
6656                operation: crate::auth::ColumnOperation::Select,
6657                columns: columns.clone(),
6658                permissions: Vec::new(),
6659            }),
6660            context,
6661            None,
6662            |table, snapshot, authorization, principal| {
6663                self.require_columns_for(
6664                    table_name,
6665                    crate::auth::ColumnOperation::Select,
6666                    &columns,
6667                    principal,
6668                )?;
6669                let hits = table.search_at_with_candidate_authorization_on_generation(
6670                    request,
6671                    snapshot,
6672                    authorization,
6673                    context,
6674                )?;
6675                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6676                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
6677                for mut hit in hits {
6678                    let row = crate::memtable::Row {
6679                        row_id: hit.row_id,
6680                        committed_epoch: crate::Epoch(0),
6681                        columns: hit
6682                            .cells
6683                            .iter()
6684                            .cloned()
6685                            .collect::<std::collections::HashMap<u16, crate::Value>>(),
6686                        deleted: false,
6687                    };
6688                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6689                    if let Some(mut row) = rows.pop() {
6690                        row.columns
6691                            .retain(|column, _| allowed_columns.contains(column));
6692                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
6693                        hit.cells.sort_by_key(|(column, _)| *column);
6694                        secured.push(hit);
6695                    }
6696                }
6697                Ok(secured)
6698            },
6699        )
6700    }
6701
6702    /// Capture one table snapshot and the security version used to authorize it.
6703    /// The caller must validate the returned version before publishing results.
6704    pub fn authorized_read_snapshot(
6705        &self,
6706        table: &str,
6707        principal: Option<&crate::auth::Principal>,
6708    ) -> Result<AuthorizedReadSnapshot> {
6709        let (security, security_version, effective_principal) = {
6710            let catalog = self.catalog.read();
6711            (
6712                catalog.security.clone(),
6713                catalog.security_version,
6714                self.principal_for_authorized_read(&catalog, principal, false)?,
6715            )
6716        };
6717        let handle = self.table(table)?;
6718        let (table_snapshot, data_generation, allowed_row_ids) = {
6719            let table_handle = handle.lock();
6720            let table_snapshot = table_handle.snapshot();
6721            let data_generation = table_handle.data_generation();
6722            let allowed = self.allowed_row_ids_locked(
6723                table,
6724                &table_handle,
6725                table_snapshot,
6726                (&security, security_version),
6727                effective_principal.as_ref(),
6728                None,
6729            )?;
6730            (
6731                table_snapshot,
6732                data_generation,
6733                allowed.map(|allowed| (*allowed).clone()),
6734            )
6735        };
6736        Ok(AuthorizedReadSnapshot {
6737            table: table.to_string(),
6738            table_snapshot,
6739            data_generation,
6740            security_version,
6741            allowed_row_ids,
6742        })
6743    }
6744
6745    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
6746        if self.catalog.read().security_version != snapshot.security_version {
6747            return false;
6748        }
6749        self.table(&snapshot.table)
6750            .ok()
6751            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
6752    }
6753
6754    pub fn rls_cache_stats(&self) -> RlsCacheStats {
6755        self.rls_cache.lock().stats()
6756    }
6757
6758    /// Read visible rows with column authorization, RLS, and masks applied.
6759    pub fn rows_for(
6760        &self,
6761        table: &str,
6762        principal: Option<&crate::auth::Principal>,
6763    ) -> Result<Vec<crate::memtable::Row>> {
6764        if principal.is_none() && self.principal.read().is_some() {
6765            self.refresh_principal()?;
6766        }
6767        let allowed = self.select_column_ids_for(table, principal)?;
6768        let handle = self.table(table)?;
6769        let rows = {
6770            let table = handle.lock();
6771            table.visible_rows(table.snapshot())?
6772        };
6773        let mut rows = self.secure_rows_for(table, rows, principal)?;
6774        for row in &mut rows {
6775            row.columns.retain(|column, _| allowed.contains(column));
6776        }
6777        Ok(rows)
6778    }
6779
6780    /// Historical rows use the current principal and security catalog against
6781    /// the row values visible at the requested snapshot.
6782    pub fn rows_at_epoch_for_current_principal(
6783        &self,
6784        table_name: &str,
6785        snapshot: Snapshot,
6786    ) -> Result<Vec<crate::memtable::Row>> {
6787        self.with_authorized_read_context(
6788            table_name,
6789            None,
6790            true,
6791            Some(&ReadAuthorization {
6792                operation: crate::auth::ColumnOperation::Select,
6793                columns: Vec::new(),
6794                permissions: Vec::new(),
6795            }),
6796            None,
6797            Some(snapshot),
6798            |table, snapshot, allowed, principal| {
6799                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6800                let mut rows = table.visible_rows(snapshot)?;
6801                if let Some(allowed) = allowed {
6802                    rows.retain(|row| allowed.contains(&row.row_id));
6803                }
6804                rows = self.secure_rows_for(table_name, rows, principal)?;
6805                for row in &mut rows {
6806                    row.columns
6807                        .retain(|column, _| allowed_columns.contains(column));
6808                }
6809                Ok(rows)
6810            },
6811        )
6812    }
6813
6814    /// Count rows visible to a principal without bypassing RLS.
6815    pub fn count_for(
6816        &self,
6817        table: &str,
6818        principal: Option<&crate::auth::Principal>,
6819    ) -> Result<u64> {
6820        if principal.is_none() && self.principal.read().is_some() {
6821            self.refresh_principal()?;
6822        }
6823        self.select_column_ids_for(table, principal)?;
6824        if self.security_active_for(table) {
6825            Ok(self.rows_for(table, principal)?.len() as u64)
6826        } else {
6827            Ok(self.table(table)?.lock().count())
6828        }
6829    }
6830
6831    /// Authorize and write one native-API row for an explicit principal.
6832    pub fn put_for(
6833        &self,
6834        table: &str,
6835        mut cells: Vec<(u16, crate::memtable::Value)>,
6836        principal: Option<&crate::auth::Principal>,
6837    ) -> Result<RowId> {
6838        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
6839        self.require_columns_for(
6840            table,
6841            crate::auth::ColumnOperation::Insert,
6842            &columns,
6843            principal,
6844        )?;
6845        let handle = self.table(table)?;
6846        let mut table_handle = handle.lock();
6847        table_handle.fill_auto_inc(&mut cells)?;
6848        table_handle.apply_defaults(&mut cells)?;
6849        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
6850        row.columns.extend(cells.iter().cloned());
6851        self.check_row_policy_for(
6852            table,
6853            crate::security::PolicyCommand::Insert,
6854            &row,
6855            true,
6856            principal,
6857        )?;
6858        table_handle.put(cells)
6859    }
6860
6861    pub fn check_row_policy_for(
6862        &self,
6863        table: &str,
6864        command: crate::security::PolicyCommand,
6865        row: &crate::memtable::Row,
6866        check_new: bool,
6867        principal: Option<&crate::auth::Principal>,
6868    ) -> Result<()> {
6869        let security = self.catalog.read().security.clone();
6870        if !security.rls_enabled(table) {
6871            return Ok(());
6872        }
6873        let cached = self.principal.read().clone();
6874        let principal = principal
6875            .or(cached.as_ref())
6876            .ok_or(MongrelError::AuthRequired)?;
6877        if security.row_allowed(table, command, row, principal, check_new) {
6878            return Ok(());
6879        }
6880        let required = match command {
6881            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
6882                table: table.to_string(),
6883            },
6884            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
6885                table: table.to_string(),
6886            },
6887            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
6888                table: table.to_string(),
6889            },
6890            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
6891                crate::auth::Permission::Delete {
6892                    table: table.to_string(),
6893                }
6894            }
6895        };
6896        Err(MongrelError::PermissionDenied {
6897            required,
6898            principal: principal.username.clone(),
6899        })
6900    }
6901
6902    /// Durably create or replace a materialized-view definition after its
6903    /// physical table has been populated.
6904    pub fn set_materialized_view(
6905        &self,
6906        definition: crate::catalog::MaterializedViewEntry,
6907    ) -> Result<()> {
6908        self.set_materialized_view_with_epoch(definition)
6909            .map(|_| ())
6910    }
6911
6912    /// Durably create or replace a materialized-view definition and return its epoch.
6913    pub fn set_materialized_view_with_epoch(
6914        &self,
6915        definition: crate::catalog::MaterializedViewEntry,
6916    ) -> Result<Epoch> {
6917        use crate::wal::DdlOp;
6918        use std::sync::atomic::Ordering;
6919
6920        let command = crate::catalog_cmds::CatalogCommand::CreateMaterializedView {
6921            definition: definition.clone(),
6922        };
6923        self.require(&crate::catalog_cmds::required_permission(&command))?;
6924        if self.poisoned.load(Ordering::Relaxed) {
6925            return Err(MongrelError::Other(
6926                "database poisoned by fsync error".into(),
6927            ));
6928        }
6929        if definition.name.is_empty() || definition.query.trim().is_empty() {
6930            return Err(MongrelError::InvalidArgument(
6931                "materialized view name and query must not be empty".into(),
6932            ));
6933        }
6934        // S1A-004: admit the materialized-view replacement as one core
6935        // operation.
6936        let _operation = self.admit_operation()?;
6937        let _ddl = self.ddl_lock.lock();
6938        let _security_write = self.security_write()?;
6939        self.require(&crate::catalog_cmds::required_permission(&command))?;
6940        // S1F-001: the backing-table check validates pure against the current
6941        // catalog first so it surfaces before an epoch is consumed, exactly
6942        // like the legacy pre-bump lookup.
6943        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
6944        let table_id = self
6945            .catalog
6946            .read()
6947            .live(&definition.name)
6948            .ok_or_else(|| {
6949                MongrelError::NotFound(format!(
6950                    "materialized view table {:?} not found",
6951                    definition.name
6952                ))
6953            })?
6954            .table_id;
6955        let definition_json = DdlOp::encode_materialized_view(&definition)?;
6956        let _commit = self.commit_lock.lock();
6957        let epoch = self.epoch.bump_assigned();
6958        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6959        let txn_id = self.alloc_txn_id()?;
6960        let mut next_catalog = self.catalog.read().clone();
6961        self.apply_catalog_command_to(&mut next_catalog, command)?;
6962        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
6963        let commit_seq = {
6964            let mut wal = self.shared_wal.lock();
6965            let append: Result<u64> = (|| {
6966                wal.append(
6967                    txn_id,
6968                    table_id,
6969                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
6970                        name: definition.name.clone(),
6971                        definition_json,
6972                    }),
6973                )?;
6974                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
6975                wal.append_commit(txn_id, epoch, &[])
6976            })();
6977            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
6978        };
6979        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
6980
6981        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
6982        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
6983        Ok(epoch)
6984    }
6985
6986    /// The filesystem root this database was opened/created at.
6987    pub fn root(&self) -> &Path {
6988        self.durable_root.canonical_path()
6989    }
6990
6991    /// Open a descriptor-pinned view of this database root for durable
6992    /// extension state such as server idempotency receipts.
6993    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
6994        Arc::clone(&self.durable_root)
6995    }
6996
6997    /// Domain-separated authentication key for server idempotency state.
6998    /// Encrypted databases derive it from the in-memory KEK. Plain databases
6999    /// return `None`; their server persists a random key under the pinned root.
7000    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
7001        self.kek
7002            .as_deref()
7003            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
7004    }
7005
7006    pub fn is_read_only_replica(&self) -> bool {
7007        self.read_only
7008    }
7009
7010    /// Reject reads whose backing state may require WAL recovery after a
7011    /// post-commit publication failure. Ordinary table/catalog state is made
7012    /// coherent before poison; file-backed external modules use this gate.
7013    pub fn ensure_consistent_read(&self) -> Result<()> {
7014        self.ensure_owner_process()?;
7015        if self.poisoned.load(Ordering::Relaxed) {
7016            return Err(MongrelError::Other(
7017                "database poisoned by post-commit failure; reopen required".into(),
7018            ));
7019        }
7020        Ok(())
7021    }
7022
7023    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
7024        self.replication_wal_retention_segments
7025            .store(segments, std::sync::atomic::Ordering::Relaxed);
7026    }
7027
7028    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
7029    /// direct table commits, compaction, and WAL append are quiesced while the
7030    /// file image is read. WAL records newer than manifests remain sufficient
7031    /// for recovery, so no flush or compaction is required.
7032    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
7033        let admin = crate::auth::Permission::Admin;
7034        self.require(&admin)?;
7035        // S1A-004: admit the bootstrap capture as one core operation.
7036        let _operation = self.admit_operation()?;
7037        let operation_principal = self.principal_snapshot();
7038        let _barrier = self.replication_barrier.write();
7039        let _ddl = self.ddl_lock.lock();
7040        let _security = self.security_coordinator.gate.read();
7041        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7042        let mut handles: Vec<_> = self
7043            .tables
7044            .read()
7045            .iter()
7046            .map(|(id, handle)| (*id, handle.clone()))
7047            .collect();
7048        handles.sort_by_key(|(id, _)| *id);
7049        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7050        let _commit = self.commit_lock.lock();
7051        let mut wal = self.shared_wal.lock();
7052        wal.group_sync()?;
7053        let io_root = self.durable_root.io_path()?;
7054        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7055        let records = crate::wal::SharedWal::replay_with_dek(&io_root, wal_dek.as_ref())?;
7056        let epoch = records
7057            .iter()
7058            .filter_map(|record| match &record.op {
7059                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
7060                _ => None,
7061            })
7062            .max()
7063            .unwrap_or(0)
7064            .max(self.epoch.committed().0);
7065        let files = crate::replication::capture_files(&io_root)?;
7066        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7067        drop(wal);
7068        Ok(crate::replication::ReplicationSnapshot::new(
7069            source_id, epoch, files,
7070        ))
7071    }
7072
7073    /// Create an online, directly-openable backup at `destination`.
7074    ///
7075    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
7076    /// mutable metadata, and pins the exact immutable runs named by the copied
7077    /// manifests. Writers resume while those runs stream into a sibling staging
7078    /// directory. A checksummed backup manifest is written last, then the stage
7079    /// is atomically renamed into place.
7080    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
7081        let control = crate::ExecutionControl::new(None);
7082        self.hot_backup_controlled(destination, &control, || true)
7083    }
7084
7085    pub(crate) fn hot_backup_to_durable_child(
7086        &self,
7087        parent: &crate::durable_file::DurableRoot,
7088        child: &Path,
7089        control: &crate::ExecutionControl,
7090    ) -> Result<crate::backup::BackupReport> {
7091        let mut components = child.components();
7092        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
7093            || components.next().is_some()
7094        {
7095            return Err(MongrelError::InvalidArgument(
7096                "durable backup child must be one relative path component".into(),
7097            ));
7098        }
7099        let destination_name = child.file_name().ok_or_else(|| {
7100            MongrelError::InvalidArgument("durable backup child has no filename".into())
7101        })?;
7102        let source_root = self.durable_root.io_path()?;
7103        let prepared = prepare_backup_destination_in(&source_root, parent, destination_name)?;
7104        self.hot_backup_prepared(prepared, control, || true)
7105    }
7106
7107    /// Build a backup cooperatively, then invoke `before_publish` immediately
7108    /// before the staging directory is atomically renamed into place.
7109    #[doc(hidden)]
7110    pub fn hot_backup_controlled<F>(
7111        &self,
7112        destination: impl AsRef<Path>,
7113        control: &crate::ExecutionControl,
7114        before_publish: F,
7115    ) -> Result<crate::backup::BackupReport>
7116    where
7117        F: FnOnce() -> bool,
7118    {
7119        let source_root = self.durable_root.io_path()?;
7120        let prepared = prepare_backup_destination(&source_root, destination.as_ref())?;
7121        self.hot_backup_prepared(prepared, control, before_publish)
7122    }
7123
7124    fn hot_backup_prepared<F>(
7125        &self,
7126        mut prepared: PreparedBackupDestination,
7127        control: &crate::ExecutionControl,
7128        before_publish: F,
7129    ) -> Result<crate::backup::BackupReport>
7130    where
7131        F: FnOnce() -> bool,
7132    {
7133        let admin = crate::auth::Permission::Admin;
7134        self.require(&admin)?;
7135        // S1A-004: admit the backup as one core operation; `shutdown()` waits
7136        // for it to drain instead of closing the core mid-copy.
7137        let _operation = self.admit_operation()?;
7138        let operation_principal = self.principal_snapshot();
7139        control.checkpoint()?;
7140        let destination = prepared.destination_path.clone();
7141        let mut before_publish = Some(before_publish);
7142
7143        let outcome = (|| {
7144            control.checkpoint()?;
7145            let barrier = self.replication_barrier.write();
7146            let ddl = self.ddl_lock.lock();
7147            let security = self.security_coordinator.gate.read();
7148            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7149            let mut handles: Vec<_> = self
7150                .tables
7151                .read()
7152                .iter()
7153                .map(|(id, handle)| (*id, handle.clone()))
7154                .collect();
7155            handles.sort_by_key(|(id, _)| *id);
7156            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7157            let commit = self.commit_lock.lock();
7158            let mut wal = self.shared_wal.lock();
7159            wal.group_sync()?;
7160            let epoch = self.epoch.committed().0;
7161            let boundary_unix_nanos = current_unix_nanos();
7162            let source_root = self.durable_root.io_path()?;
7163
7164            let pin_nonce = std::time::SystemTime::now()
7165                .duration_since(std::time::UNIX_EPOCH)
7166                .unwrap_or_default()
7167                .as_nanos();
7168            let file_pin_root = source_root
7169                .join(META_DIR)
7170                .join("backup-pins")
7171                .join(format!("{}-{pin_nonce}", std::process::id()));
7172            std::fs::create_dir_all(&file_pin_root)?;
7173            let _file_pins = BackupFilePins {
7174                root: file_pin_root.clone(),
7175            };
7176            let mut run_files = Vec::new();
7177            for (index, (table_id, _)) in handles.iter().enumerate() {
7178                if index % 256 == 0 {
7179                    control.checkpoint()?;
7180                }
7181                let table = &table_guards[index];
7182                for (run_index, run) in table.run_refs().iter().enumerate() {
7183                    if run_index % 256 == 0 {
7184                        control.checkpoint()?;
7185                    }
7186                    let source = table.run_path(run.run_id as u64);
7187                    let relative = Path::new(TABLES_DIR)
7188                        .join(table_id.to_string())
7189                        .join(crate::engine::RUNS_DIR)
7190                        .join(format!("r-{}.sr", run.run_id));
7191                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
7192                    if std::fs::hard_link(&source, &pinned).is_err() {
7193                        crate::backup::copy_file_synced(&source, &pinned)?;
7194                    }
7195                    run_files.push(((*table_id, run.run_id), pinned, relative));
7196                }
7197            }
7198            crate::durable_file::sync_directory(&file_pin_root)?;
7199            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
7200            {
7201                let mut pins = self.backup_pins.lock();
7202                for key in &run_keys {
7203                    *pins.entry(*key).or_insert(0) += 1;
7204                }
7205            }
7206            let _run_pins = RunPins {
7207                pins: Arc::clone(&self.backup_pins),
7208                runs: run_keys,
7209            };
7210            // S1C-004: mirror the backup boundary into every table's unified
7211            // pin registry (PinSource::BackupPitr covers PITR base captures,
7212            // which route through this same path). Version GC must not
7213            // reclaim the boundary versions while their runs stream into the
7214            // stage, and diagnostics must expose the pin. The guards drop
7215            // with `_run_pins` when the backup finishes or fails.
7216            let _registry_pins: Vec<crate::retention::PinGuard> = table_guards
7217                .iter()
7218                .map(|table| {
7219                    table
7220                        .pin_registry()
7221                        .pin(crate::retention::PinSource::BackupPitr, Epoch(epoch))
7222                })
7223                .collect();
7224            let deferred: HashSet<_> = run_files
7225                .iter()
7226                .map(|(_, _, relative)| relative.clone())
7227                .collect();
7228            let mut copied = Vec::new();
7229            copy_backup_boundary(
7230                &source_root,
7231                prepared.stage.as_deref().ok_or_else(|| {
7232                    MongrelError::Other("backup staging root was already released".into())
7233                })?,
7234                &deferred,
7235                &mut copied,
7236                Some(control),
7237            )?;
7238
7239            drop(wal);
7240            drop(commit);
7241            drop(table_guards);
7242            drop(security);
7243            drop(ddl);
7244            drop(barrier);
7245
7246            if let Some(hook) = self.backup_hook.lock().as_ref() {
7247                hook();
7248            }
7249            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
7250                if index % 256 == 0 {
7251                    control.checkpoint()?;
7252                }
7253                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
7254                prepared
7255                    .stage
7256                    .as_deref()
7257                    .ok_or_else(|| {
7258                        MongrelError::Other("backup staging root was already released".into())
7259                    })?
7260                    .copy_new_from(&relative, &mut source)?;
7261                copied.push(relative);
7262            }
7263
7264            let manifest = crate::backup::BackupManifest::create_controlled_durable(
7265                prepared.stage.as_deref().ok_or_else(|| {
7266                    MongrelError::Other("backup staging root was already released".into())
7267                })?,
7268                epoch,
7269                &copied,
7270                control,
7271            )?;
7272            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
7273                MongrelError::Other("backup staging root was already released".into())
7274            })?)?;
7275            control.checkpoint()?;
7276            let publish = before_publish.take().ok_or_else(|| {
7277                MongrelError::Other("backup publication callback already consumed".into())
7278            })?;
7279            if !publish() {
7280                return Err(MongrelError::Cancelled);
7281            }
7282            let final_security = self.security_coordinator.gate.read();
7283            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7284            // Windows pins directories without delete sharing. Release the
7285            // stage handle before renaming that directory, while the parent
7286            // remains descriptor-pinned for the no-replace publication.
7287            drop(prepared.stage.take().ok_or_else(|| {
7288                MongrelError::Other("backup staging root was already released".into())
7289            })?);
7290            let published = std::cell::Cell::new(false);
7291            if let Err(error) = prepared.parent.rename_directory_new_with_after(
7292                Path::new(&prepared.stage_name),
7293                &prepared.parent,
7294                Path::new(&prepared.destination_name),
7295                || published.set(true),
7296            ) {
7297                if published.get() {
7298                    return Err(MongrelError::CommitOutcomeUnknown {
7299                        epoch,
7300                        message: format!("backup publication was not durable: {error}"),
7301                    });
7302                }
7303                return Err(error.into());
7304            }
7305            drop(final_security);
7306            Ok(crate::backup::BackupReport {
7307                destination,
7308                epoch,
7309                boundary_unix_nanos,
7310                files: manifest.files.len(),
7311                bytes: manifest.total_bytes(),
7312            })
7313        })();
7314
7315        if outcome.is_err() {
7316            drop(prepared.stage.take());
7317            let _ = prepared
7318                .parent
7319                .remove_directory_all(Path::new(&prepared.stage_name));
7320        }
7321        outcome
7322    }
7323
7324    /// Return complete committed transactions after `since_epoch`. A gap or a
7325    /// transaction backed by a spilled run requires a fresh bootstrap image.
7326    pub fn replication_batch_since(
7327        &self,
7328        since_epoch: u64,
7329    ) -> Result<crate::replication::ReplicationBatch> {
7330        use crate::wal::Op;
7331
7332        let admin = crate::auth::Permission::Admin;
7333        self.require(&admin)?;
7334        // S1A-004: admit the batch extraction as one core operation.
7335        let _operation = self.admit_operation()?;
7336        let operation_principal = self.principal_snapshot();
7337
7338        let mut wal = self.shared_wal.lock();
7339        wal.group_sync()?;
7340        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7341        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
7342        drop(wal);
7343
7344        let commits: HashMap<u64, u64> = records
7345            .iter()
7346            .filter_map(|record| match &record.op {
7347                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
7348                _ => None,
7349            })
7350            .collect();
7351        let earliest_epoch = commits.values().copied().min();
7352        let current_epoch = commits
7353            .values()
7354            .copied()
7355            .max()
7356            .unwrap_or(0)
7357            .max(self.epoch.committed().0);
7358        let selected: HashSet<u64> = commits
7359            .iter()
7360            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
7361            .collect();
7362        let retention_gap = since_epoch < current_epoch
7363            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
7364        let spilled = records.iter().any(|record| {
7365            selected.contains(&record.txn_id)
7366                && matches!(
7367                    &record.op,
7368                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
7369                )
7370        });
7371        let records = records
7372            .into_iter()
7373            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
7374            .filter(|record| selected.contains(&record.txn_id))
7375            .collect();
7376        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7377        let batch = crate::replication::ReplicationBatch::complete_for_source(
7378            source_id,
7379            since_epoch,
7380            current_epoch,
7381            earliest_epoch,
7382            retention_gap,
7383            spilled,
7384            records,
7385        )?;
7386        if let Some(hook) = self.replication_hook.lock().as_ref() {
7387            hook();
7388        }
7389        let _security = self.security_coordinator.gate.read();
7390        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7391        Ok(batch)
7392    }
7393
7394    /// Durably append a leader batch to a follower's local WAL and checkpoint
7395    /// its catalog metadata. Security changes apply to this live handle before
7396    /// success returns. The caller must reopen to mount new table state.
7397    pub fn append_replication_batch(
7398        &self,
7399        batch: &crate::replication::ReplicationBatch,
7400    ) -> Result<u64> {
7401        use crate::wal::Op;
7402
7403        if !self.read_only {
7404            return Err(MongrelError::InvalidArgument(
7405                "replication batches may only target a marked replica".into(),
7406            ));
7407        }
7408        // S1A-004: admit the follow-apply as one core operation.
7409        let _operation = self.admit_operation()?;
7410        let current = crate::replication::replica_epoch(&self.root)?;
7411        if batch.is_source_bound() {
7412            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
7413            if batch.source_id != source_id {
7414                return Err(MongrelError::Conflict(
7415                    "replication batch source does not match follower binding".into(),
7416                ));
7417            }
7418        }
7419        if batch.requires_snapshot {
7420            return Err(MongrelError::Conflict(
7421                "replication snapshot required for this batch".into(),
7422            ));
7423        }
7424        batch.validate_proof()?;
7425        if batch.from_epoch != current {
7426            if batch.from_epoch < current && batch.current_epoch == current {
7427                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7428                let _wal = self.shared_wal.lock();
7429                let existing: HashSet<(u64, u64)> =
7430                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7431                        .into_iter()
7432                        .filter_map(|record| match record.op {
7433                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7434                            _ => None,
7435                        })
7436                        .collect();
7437                let already_applied = batch.records.iter().all(|record| match &record.op {
7438                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
7439                    _ => true,
7440                });
7441                if already_applied {
7442                    return Ok(current);
7443                }
7444            }
7445            return Err(MongrelError::Conflict(format!(
7446                "replication batch starts at epoch {}, follower is at epoch {current}",
7447                batch.from_epoch
7448            )));
7449        }
7450        if batch.current_epoch < current {
7451            return Err(MongrelError::InvalidArgument(format!(
7452                "replication batch current epoch {} precedes follower epoch {current}",
7453                batch.current_epoch
7454            )));
7455        }
7456        let records = &batch.records;
7457        let mut commits = HashMap::new();
7458        let mut commit_epochs = HashSet::new();
7459        let mut commit_timestamps = HashMap::new();
7460        for record in records {
7461            match &record.op {
7462                Op::TxnCommit { epoch, added_runs } => {
7463                    if !added_runs.is_empty() {
7464                        return Err(MongrelError::Conflict(
7465                            "replication snapshot required for spilled-run transaction".into(),
7466                        ));
7467                    }
7468                    if commits.insert(record.txn_id, *epoch).is_some() {
7469                        return Err(MongrelError::InvalidArgument(format!(
7470                            "duplicate commit for replication transaction {}",
7471                            record.txn_id
7472                        )));
7473                    }
7474                    if *epoch <= current || *epoch > batch.current_epoch {
7475                        return Err(MongrelError::InvalidArgument(format!(
7476                            "replication commit epoch {epoch} is outside ({current}, {}]",
7477                            batch.current_epoch
7478                        )));
7479                    }
7480                    if !commit_epochs.insert(*epoch) {
7481                        return Err(MongrelError::InvalidArgument(format!(
7482                            "duplicate replication commit epoch {epoch}"
7483                        )));
7484                    }
7485                }
7486                Op::CommitTimestamp { unix_nanos } => {
7487                    commit_timestamps.insert(record.txn_id, *unix_nanos);
7488                }
7489                _ => {}
7490            }
7491        }
7492        for record in records {
7493            if record.txn_id == crate::wal::SYSTEM_TXN_ID
7494                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
7495            {
7496                return Err(MongrelError::InvalidArgument(
7497                    "replication batch contains a non-committed record".into(),
7498                ));
7499            }
7500            if !commits.contains_key(&record.txn_id) {
7501                return Err(MongrelError::InvalidArgument(format!(
7502                    "incomplete replication transaction {}",
7503                    record.txn_id
7504                )));
7505            }
7506        }
7507        let target_epoch = commits
7508            .values()
7509            .copied()
7510            .filter(|epoch| *epoch > current)
7511            .max()
7512            .unwrap_or(current);
7513        if target_epoch != batch.current_epoch {
7514            return Err(MongrelError::InvalidArgument(format!(
7515                "replication batch ends at epoch {target_epoch}, expected {}",
7516                batch.current_epoch
7517            )));
7518        }
7519        let mut selected: HashSet<u64> = commits
7520            .iter()
7521            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
7522            .collect();
7523        if selected.is_empty() {
7524            return Ok(current);
7525        }
7526        let mut wal = self.shared_wal.lock();
7527        wal.group_sync()?;
7528        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7529        let existing: HashSet<(u64, u64)> =
7530            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7531                .into_iter()
7532                .filter_map(|record| match record.op {
7533                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7534                    _ => None,
7535                })
7536                .collect();
7537        selected.retain(|txn_id| {
7538            commits
7539                .get(txn_id)
7540                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
7541        });
7542        for record in records {
7543            if !selected.contains(&record.txn_id) {
7544                continue;
7545            }
7546            match &record.op {
7547                Op::TxnCommit { epoch, added_runs } => {
7548                    let timestamp = commit_timestamps
7549                        .get(&record.txn_id)
7550                        .copied()
7551                        .unwrap_or_else(current_unix_nanos);
7552                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
7553                }
7554                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
7555                op => {
7556                    wal.append(record.txn_id, 0, op.clone())?;
7557                }
7558            }
7559        }
7560        if !selected.is_empty() {
7561            wal.group_sync()?;
7562        }
7563        drop(wal);
7564
7565        // Auth mode is selected before `finish_open` replays the WAL. Make the
7566        // catalog transition durable and publish security state to this live
7567        // handle before reporting success.
7568        let mut recovered_catalog = self.catalog.read().clone();
7569        if let Err(error) = recover_ddl_from_wal(
7570            &self.root,
7571            Some(&self.durable_root),
7572            &mut recovered_catalog,
7573            self.meta_dek.as_ref(),
7574            wal_dek.as_ref(),
7575            true,
7576            None,
7577        ) {
7578            return Err(MongrelError::DurableCommit {
7579                epoch: target_epoch,
7580                message: format!(
7581                    "replication WAL is durable but catalog checkpoint failed: {error}"
7582                ),
7583            });
7584        }
7585        let _security = self.security_coordinator.gate.write();
7586        let old_security_version = self.catalog.read().security_version;
7587        let security_changed = old_security_version != recovered_catalog.security_version
7588            || self.catalog.read().require_auth != recovered_catalog.require_auth;
7589        let require_auth = recovered_catalog.require_auth;
7590        let principal = if security_changed {
7591            None
7592        } else {
7593            self.principal.read().as_ref().and_then(|principal| {
7594                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
7595            })
7596        };
7597        if require_auth {
7598            self.auth_state.set_require_auth(true);
7599        }
7600        self.auth_state.set_principal(principal.clone());
7601        *self.principal.write() = principal;
7602        let security_version = recovered_catalog.security_version;
7603        *self.catalog.write() = recovered_catalog;
7604        self.security_coordinator
7605            .version
7606            .store(security_version, Ordering::Release);
7607        if !require_auth {
7608            self.auth_state.set_require_auth(false);
7609        }
7610        if let Err(error) =
7611            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
7612        {
7613            return Err(MongrelError::DurableCommit {
7614                epoch: target_epoch,
7615                message: format!(
7616                    "replication WAL and catalog are durable but follower watermark failed: {error}"
7617                ),
7618            });
7619        }
7620        Ok(target_epoch)
7621    }
7622
7623    /// Resolve a table name → id (live tables only). pub(crate) so the
7624    /// transaction layer can stage by name.
7625    pub fn table_id(&self, name: &str) -> Result<u64> {
7626        let cat = self.catalog.read();
7627        cat.live(name)
7628            .map(|e| e.table_id)
7629            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
7630    }
7631
7632    /// Return the stable table id and current schema generation from one
7633    /// catalog snapshot. Callers can bind retries to this identity so a table
7634    /// dropped and recreated under the same name is never mistaken for the
7635    /// original resource.
7636    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
7637        let catalog = self.catalog.read();
7638        catalog
7639            .live(name)
7640            .map(|entry| (entry.table_id, entry.schema.schema_id))
7641            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
7642    }
7643
7644    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
7645        self.catalog
7646            .read()
7647            .building(name)
7648            .map(|entry| entry.table_id)
7649            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
7650    }
7651
7652    pub fn procedures(&self) -> Vec<StoredProcedure> {
7653        self.catalog
7654            .read()
7655            .procedures
7656            .iter()
7657            .map(|p| p.procedure.clone())
7658            .collect()
7659    }
7660
7661    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
7662        self.catalog
7663            .read()
7664            .procedures
7665            .iter()
7666            .find(|p| p.procedure.name == name)
7667            .map(|p| p.procedure.clone())
7668    }
7669
7670    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
7671        self.create_procedure_inner(procedure, None)
7672    }
7673
7674    pub fn create_procedure_controlled<F>(
7675        &self,
7676        procedure: StoredProcedure,
7677        mut before_publish: F,
7678    ) -> Result<StoredProcedure>
7679    where
7680        F: FnMut() -> Result<()>,
7681    {
7682        self.create_procedure_inner(procedure, Some(&mut before_publish))
7683    }
7684
7685    fn create_procedure_inner(
7686        &self,
7687        mut procedure: StoredProcedure,
7688        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7689    ) -> Result<StoredProcedure> {
7690        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
7691            procedure: procedure.clone(),
7692        };
7693        self.require(&crate::catalog_cmds::required_permission(&command))?;
7694        let _g = self.ddl_lock.lock();
7695        let _security_write = self.security_write()?;
7696        self.require(&crate::catalog_cmds::required_permission(&command))?;
7697        procedure.validate()?;
7698        self.validate_procedure_references(&procedure)?;
7699        // S1F-001: validation runs pure against the current catalog first so
7700        // failures (duplicate name, unknown table) surface before an epoch is
7701        // consumed, exactly like the legacy pre-bump checks.
7702        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7703        let commit_lock = Arc::clone(&self.commit_lock);
7704        let _c = commit_lock.lock();
7705        let epoch = self.epoch.bump_assigned();
7706        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7707        procedure.created_epoch = epoch.0;
7708        procedure.updated_epoch = epoch.0;
7709        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
7710            procedure: procedure.clone(),
7711        };
7712        let mut next_catalog = self.catalog.read().clone();
7713        self.apply_catalog_command_to(&mut next_catalog, command)?;
7714        next_catalog.db_epoch = epoch.0;
7715        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7716        Ok(procedure)
7717    }
7718
7719    pub fn create_or_replace_procedure(
7720        &self,
7721        procedure: StoredProcedure,
7722    ) -> Result<StoredProcedure> {
7723        self.create_or_replace_procedure_inner(procedure, None)
7724    }
7725
7726    pub fn create_or_replace_procedure_controlled<F>(
7727        &self,
7728        procedure: StoredProcedure,
7729        mut before_publish: F,
7730    ) -> Result<StoredProcedure>
7731    where
7732        F: FnMut() -> Result<()>,
7733    {
7734        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
7735    }
7736
7737    fn create_or_replace_procedure_inner(
7738        &self,
7739        procedure: StoredProcedure,
7740        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7741    ) -> Result<StoredProcedure> {
7742        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
7743            procedure: procedure.clone(),
7744        };
7745        self.require(&crate::catalog_cmds::required_permission(&command))?;
7746        let _g = self.ddl_lock.lock();
7747        let _security_write = self.security_write()?;
7748        self.require(&crate::catalog_cmds::required_permission(&command))?;
7749        procedure.validate()?;
7750        self.validate_procedure_references(&procedure)?;
7751        // S1F-001: validation runs pure against the current catalog first so
7752        // structural failures surface before an epoch is consumed, exactly
7753        // like the legacy pre-bump checks.
7754        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7755        let commit_lock = Arc::clone(&self.commit_lock);
7756        let _c = commit_lock.lock();
7757        let epoch = self.epoch.bump_assigned();
7758        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7759        let mut next_catalog = self.catalog.read().clone();
7760        // Resolve the replacement image against the candidate catalog: the
7761        // engine stamps version/epochs from the commit epoch (preserving the
7762        // original created_epoch on replacement), then the command record
7763        // carries that resolved image.
7764        let replaced = match next_catalog
7765            .procedures
7766            .iter()
7767            .position(|p| p.procedure.name == procedure.name)
7768        {
7769            Some(idx) => next_catalog.procedures[idx]
7770                .procedure
7771                .replaced(procedure.clone(), epoch.0)?,
7772            None => {
7773                let mut next = procedure;
7774                next.created_epoch = epoch.0;
7775                next.updated_epoch = epoch.0;
7776                next
7777            }
7778        };
7779        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
7780            procedure: replaced.clone(),
7781        };
7782        self.apply_catalog_command_to(&mut next_catalog, command)?;
7783        next_catalog.db_epoch = epoch.0;
7784        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7785        Ok(replaced)
7786    }
7787
7788    pub fn drop_procedure(&self, name: &str) -> Result<()> {
7789        self.drop_procedure_with_epoch(name).map(|_| ())
7790    }
7791
7792    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
7793        self.drop_procedure_with_epoch_inner(name, None)
7794    }
7795
7796    pub fn drop_procedure_with_epoch_controlled<F>(
7797        &self,
7798        name: &str,
7799        mut before_publish: F,
7800    ) -> Result<Epoch>
7801    where
7802        F: FnMut() -> Result<()>,
7803    {
7804        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
7805    }
7806
7807    fn drop_procedure_with_epoch_inner(
7808        &self,
7809        name: &str,
7810        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7811    ) -> Result<Epoch> {
7812        let command = crate::catalog_cmds::CatalogCommand::DropProcedure {
7813            name: name.to_string(),
7814        };
7815        self.require(&crate::catalog_cmds::required_permission(&command))?;
7816        let _g = self.ddl_lock.lock();
7817        let _security_write = self.security_write()?;
7818        self.require(&crate::catalog_cmds::required_permission(&command))?;
7819        let commit_lock = Arc::clone(&self.commit_lock);
7820        let _c = commit_lock.lock();
7821        let epoch = self.epoch.bump_assigned();
7822        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7823        let mut next_catalog = self.catalog.read().clone();
7824        self.apply_catalog_command_to(&mut next_catalog, command)?;
7825        next_catalog.db_epoch = epoch.0;
7826        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7827        Ok(epoch)
7828    }
7829
7830    // ── User / role / credentials management ─────────────────────────────
7831
7832    /// List all catalog users (password hashes included — callers should not
7833    /// serialize them externally).
7834    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
7835        self.catalog.read().users.clone()
7836    }
7837
7838    /// Resolve only the stable, non-secret identity fields needed to scope
7839    /// request receipts. Password hashes never leave the catalog lock.
7840    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
7841        self.catalog
7842            .read()
7843            .users
7844            .iter()
7845            .find(|user| user.username == username)
7846            .map(|user| (user.id, user.created_epoch))
7847    }
7848
7849    /// SCRAM verifier material for the current catalog user generation.
7850    pub fn user_scram_verifier(
7851        &self,
7852        username: &str,
7853    ) -> Option<crate::security_hardening::ScramVerifier> {
7854        self.catalog
7855            .read()
7856            .users
7857            .iter()
7858            .find(|user| user.username == username)
7859            .and_then(|user| user.scram_sha_256.clone())
7860    }
7861
7862    /// Current catalog authorization generation. Retry bindings can include
7863    /// this value to fail closed after roles, grants, or row policies change.
7864    pub fn security_version(&self) -> u64 {
7865        self.catalog.read().security_version
7866    }
7867
7868    /// List all catalog roles.
7869    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
7870        self.catalog.read().roles.clone()
7871    }
7872
7873    /// Create a new user with an Argon2id-hashed password.
7874    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
7875        self.require(&crate::auth::Permission::Admin)?;
7876        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
7877        let scram = crate::auth::scram_verifier(password).map_err(MongrelError::Other)?;
7878        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(password);
7879        self.create_user_with_password_hash_inner(username, hash, Some((scram, mysql)), None)
7880    }
7881
7882    /// Create a user from a password hash prepared before a commit fence.
7883    pub fn create_user_with_password_hash(
7884        &self,
7885        username: &str,
7886        hash: String,
7887    ) -> Result<crate::auth::UserEntry> {
7888        self.create_user_with_password_hash_inner(username, hash, None, None)
7889    }
7890
7891    pub fn create_user_with_password_hash_controlled<F>(
7892        &self,
7893        username: &str,
7894        hash: String,
7895        mut before_publish: F,
7896    ) -> Result<crate::auth::UserEntry>
7897    where
7898        F: FnMut() -> Result<()>,
7899    {
7900        self.create_user_with_password_hash_inner(username, hash, None, Some(&mut before_publish))
7901    }
7902
7903    fn create_user_with_password_hash_inner(
7904        &self,
7905        username: &str,
7906        hash: String,
7907        verifiers: Option<(
7908            crate::security_hardening::ScramVerifier,
7909            crate::auth::MysqlCachingSha2Verifier,
7910        )>,
7911        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7912    ) -> Result<crate::auth::UserEntry> {
7913        // S1F-001: the mutation is a versioned catalog command; its required
7914        // permission is checked against the caller principal first (identical
7915        // to the legacy hardcoded Admin gate).
7916        let command = match &verifiers {
7917            Some((scram_sha_256, mysql_caching_sha2)) => {
7918                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
7919                    username: username.to_string(),
7920                    password_hash: hash.clone(),
7921                    scram_sha_256: scram_sha_256.clone(),
7922                    mysql_caching_sha2: mysql_caching_sha2.clone(),
7923                    is_admin: false,
7924                    created_epoch: 0,
7925                }
7926            }
7927            None => crate::catalog_cmds::CatalogCommand::CreateUser {
7928                username: username.to_string(),
7929                password_hash: hash.clone(),
7930                is_admin: false,
7931                created_epoch: 0,
7932            },
7933        };
7934        self.require(&crate::catalog_cmds::required_permission(&command))?;
7935        let _ddl = self.ddl_lock.lock();
7936        let _security_write = self.security_write()?;
7937        self.require(&crate::catalog_cmds::required_permission(&command))?;
7938        let _commit = self.commit_lock.lock();
7939        let epoch = self.epoch.bump_assigned();
7940        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7941        let command = match verifiers {
7942            Some((scram_sha_256, mysql_caching_sha2)) => {
7943                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
7944                    username: username.to_string(),
7945                    password_hash: hash,
7946                    scram_sha_256,
7947                    mysql_caching_sha2,
7948                    is_admin: false,
7949                    created_epoch: epoch.0,
7950                }
7951            }
7952            None => crate::catalog_cmds::CatalogCommand::CreateUser {
7953                username: username.to_string(),
7954                password_hash: hash,
7955                is_admin: false,
7956                created_epoch: epoch.0,
7957            },
7958        };
7959        let mut next_catalog = self.catalog.read().clone();
7960        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
7961        let crate::catalog_cmds::CatalogDelta::UserUpserted(entry) = delta else {
7962            return Err(MongrelError::Other(
7963                "CreateUser resolved to an unexpected catalog delta".into(),
7964            ));
7965        };
7966        next_catalog.db_epoch = epoch.0;
7967        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
7968        Ok(entry)
7969    }
7970
7971    /// Drop a user by username.
7972    pub fn drop_user(&self, username: &str) -> Result<()> {
7973        self.drop_user_with_epoch(username).map(|_| ())
7974    }
7975
7976    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
7977        self.drop_user_with_epoch_inner(username, None)
7978    }
7979
7980    pub fn drop_user_with_epoch_controlled<F>(
7981        &self,
7982        username: &str,
7983        mut before_publish: F,
7984    ) -> Result<Epoch>
7985    where
7986        F: FnMut() -> Result<()>,
7987    {
7988        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
7989    }
7990
7991    fn drop_user_with_epoch_inner(
7992        &self,
7993        username: &str,
7994        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
7995    ) -> Result<Epoch> {
7996        let command = crate::catalog_cmds::CatalogCommand::DropUser {
7997            username: username.to_string(),
7998        };
7999        self.require(&crate::catalog_cmds::required_permission(&command))?;
8000        let _ddl = self.ddl_lock.lock();
8001        let _security_write = self.security_write()?;
8002        self.require(&crate::catalog_cmds::required_permission(&command))?;
8003        let _commit = self.commit_lock.lock();
8004        let epoch = self.epoch.bump_assigned();
8005        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8006        let mut next_catalog = self.catalog.read().clone();
8007        self.apply_catalog_command_to(&mut next_catalog, command)?;
8008        next_catalog.db_epoch = epoch.0;
8009        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8010        Ok(epoch)
8011    }
8012
8013    /// Change a user's password.
8014    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
8015        self.alter_user_password_with_epoch(username, new_password)
8016            .map(|_| ())
8017    }
8018
8019    pub fn alter_user_password_with_epoch(
8020        &self,
8021        username: &str,
8022        new_password: &str,
8023    ) -> Result<Epoch> {
8024        self.require(&crate::auth::Permission::Admin)?;
8025        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
8026        let scram = crate::auth::scram_verifier(new_password).map_err(MongrelError::Other)?;
8027        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(new_password);
8028        self.alter_user_password_hash_with_epoch_inner(username, hash, Some((scram, mysql)), None)
8029    }
8030
8031    pub fn alter_user_password_hash_with_epoch(
8032        &self,
8033        username: &str,
8034        hash: String,
8035    ) -> Result<Epoch> {
8036        self.alter_user_password_hash_with_epoch_inner(username, hash, None, None)
8037    }
8038
8039    pub fn alter_user_password_hash_with_epoch_controlled<F>(
8040        &self,
8041        username: &str,
8042        hash: String,
8043        mut before_publish: F,
8044    ) -> Result<Epoch>
8045    where
8046        F: FnMut() -> Result<()>,
8047    {
8048        self.alter_user_password_hash_with_epoch_inner(
8049            username,
8050            hash,
8051            None,
8052            Some(&mut before_publish),
8053        )
8054    }
8055
8056    fn alter_user_password_hash_with_epoch_inner(
8057        &self,
8058        username: &str,
8059        hash: String,
8060        verifiers: Option<(
8061            crate::security_hardening::ScramVerifier,
8062            crate::auth::MysqlCachingSha2Verifier,
8063        )>,
8064        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8065    ) -> Result<Epoch> {
8066        let command = match verifiers {
8067            Some((scram_sha_256, mysql_caching_sha2)) => {
8068                crate::catalog_cmds::CatalogCommand::AlterUserPasswordWithAuthVerifiers {
8069                    username: username.to_string(),
8070                    password_hash: hash,
8071                    scram_sha_256,
8072                    mysql_caching_sha2,
8073                }
8074            }
8075            None => crate::catalog_cmds::CatalogCommand::AlterUserPassword {
8076                username: username.to_string(),
8077                password_hash: hash,
8078            },
8079        };
8080        self.require(&crate::catalog_cmds::required_permission(&command))?;
8081        let _ddl = self.ddl_lock.lock();
8082        let _security_write = self.security_write()?;
8083        self.require(&crate::catalog_cmds::required_permission(&command))?;
8084        let _commit = self.commit_lock.lock();
8085        let epoch = self.epoch.bump_assigned();
8086        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8087        let mut next_catalog = self.catalog.read().clone();
8088        self.apply_catalog_command_to(&mut next_catalog, command)?;
8089        next_catalog.db_epoch = epoch.0;
8090        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8091        Ok(epoch)
8092    }
8093
8094    /// Verify credentials. Returns `Some(entry)` on success, `None` on
8095    /// mismatch, `Err` on engine error.
8096    pub fn verify_user(
8097        &self,
8098        username: &str,
8099        password: &str,
8100    ) -> Result<Option<crate::auth::UserEntry>> {
8101        let cat = self.catalog.read();
8102        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
8103            return Ok(None);
8104        };
8105        if user.password_hash.is_empty() {
8106            return Ok(None);
8107        }
8108        let ok = crate::auth::verify_password(password, &user.password_hash)
8109            .map_err(MongrelError::Other)?;
8110        if ok {
8111            Ok(Some(user.clone()))
8112        } else {
8113            Ok(None)
8114        }
8115    }
8116
8117    /// Authenticate and resolve one immutable principal from the same catalog
8118    /// snapshot. Username reuse cannot bridge the password check and principal
8119    /// resolution.
8120    pub fn authenticate_principal(
8121        &self,
8122        username: &str,
8123        password: &str,
8124    ) -> Result<Option<crate::auth::Principal>> {
8125        self.authenticate_principal_inner(username, password, || {})
8126    }
8127
8128    fn authenticate_principal_inner<F>(
8129        &self,
8130        username: &str,
8131        password: &str,
8132        after_verify: F,
8133    ) -> Result<Option<crate::auth::Principal>>
8134    where
8135        F: FnOnce(),
8136    {
8137        let catalog = self.catalog.read();
8138        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
8139            return Ok(None);
8140        };
8141        if user.password_hash.is_empty()
8142            || !crate::auth::verify_password(password, &user.password_hash)
8143                .map_err(MongrelError::Other)?
8144        {
8145            return Ok(None);
8146        }
8147        after_verify();
8148        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
8149    }
8150
8151    /// Verify a MySQL 8 `caching_sha2_password` proof and resolve the same
8152    /// live catalog principal used by native SCRAM authentication.
8153    pub fn authenticate_mysql_caching_sha2_principal(
8154        &self,
8155        username: &str,
8156        nonce: &[u8],
8157        proof: &[u8],
8158    ) -> Option<crate::auth::Principal> {
8159        let catalog = self.catalog.read();
8160        let user = catalog
8161            .users
8162            .iter()
8163            .find(|user| user.username == username)?;
8164        if !user.mysql_caching_sha2.as_ref()?.verify(nonce, proof) {
8165            return None;
8166        }
8167        Self::resolve_user_principal_from_catalog(&catalog, user)
8168    }
8169
8170    /// Grant admin privileges to a user (bypasses all permission checks).
8171    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
8172        self.set_user_admin_with_epoch(username, is_admin)
8173            .map(|_| ())
8174    }
8175
8176    pub fn set_user_admin_with_epoch(
8177        &self,
8178        username: &str,
8179        is_admin: bool,
8180    ) -> Result<Option<Epoch>> {
8181        self.set_user_admin_with_epoch_inner(username, is_admin, None)
8182    }
8183
8184    pub fn set_user_admin_with_epoch_controlled<F>(
8185        &self,
8186        username: &str,
8187        is_admin: bool,
8188        mut before_publish: F,
8189    ) -> Result<Option<Epoch>>
8190    where
8191        F: FnMut() -> Result<()>,
8192    {
8193        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
8194    }
8195
8196    fn set_user_admin_with_epoch_inner(
8197        &self,
8198        username: &str,
8199        is_admin: bool,
8200        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8201    ) -> Result<Option<Epoch>> {
8202        let command = crate::catalog_cmds::CatalogCommand::SetUserAdmin {
8203            username: username.to_string(),
8204            is_admin,
8205        };
8206        self.require(&crate::catalog_cmds::required_permission(&command))?;
8207        let _ddl = self.ddl_lock.lock();
8208        let _security_write = self.security_write()?;
8209        self.require(&crate::catalog_cmds::required_permission(&command))?;
8210        let _commit = self.commit_lock.lock();
8211        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8212        // short-circuit), so validation runs pure first and only real changes
8213        // are applied and recorded.
8214        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8215        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8216            return Ok(None);
8217        }
8218        let epoch = self.epoch.bump_assigned();
8219        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8220        let mut next_catalog = self.catalog.read().clone();
8221        self.apply_catalog_command_to(&mut next_catalog, command)?;
8222        next_catalog.db_epoch = epoch.0;
8223        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8224        Ok(Some(epoch))
8225    }
8226
8227    /// Create a new role.
8228    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
8229        self.create_role_inner(name, None)
8230    }
8231
8232    pub fn create_role_controlled<F>(
8233        &self,
8234        name: &str,
8235        mut before_publish: F,
8236    ) -> Result<crate::auth::RoleEntry>
8237    where
8238        F: FnMut() -> Result<()>,
8239    {
8240        self.create_role_inner(name, Some(&mut before_publish))
8241    }
8242
8243    fn create_role_inner(
8244        &self,
8245        name: &str,
8246        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8247    ) -> Result<crate::auth::RoleEntry> {
8248        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8249            name: name.to_string(),
8250            created_epoch: 0, // stamped after the commit epoch is assigned
8251        };
8252        self.require(&crate::catalog_cmds::required_permission(&command))?;
8253        let _ddl = self.ddl_lock.lock();
8254        let _security_write = self.security_write()?;
8255        self.require(&crate::catalog_cmds::required_permission(&command))?;
8256        let _commit = self.commit_lock.lock();
8257        let epoch = self.epoch.bump_assigned();
8258        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8259        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8260            name: name.to_string(),
8261            created_epoch: epoch.0,
8262        };
8263        let mut next_catalog = self.catalog.read().clone();
8264        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8265        let crate::catalog_cmds::CatalogDelta::RoleUpserted(entry) = delta else {
8266            return Err(MongrelError::Other(
8267                "CreateRole resolved to an unexpected catalog delta".into(),
8268            ));
8269        };
8270        next_catalog.db_epoch = epoch.0;
8271        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8272        Ok(entry)
8273    }
8274
8275    /// Drop a role by name.
8276    pub fn drop_role(&self, name: &str) -> Result<()> {
8277        self.drop_role_with_epoch(name).map(|_| ())
8278    }
8279
8280    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
8281        self.drop_role_with_epoch_inner(name, None)
8282    }
8283
8284    pub fn drop_role_with_epoch_controlled<F>(
8285        &self,
8286        name: &str,
8287        mut before_publish: F,
8288    ) -> Result<Epoch>
8289    where
8290        F: FnMut() -> Result<()>,
8291    {
8292        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
8293    }
8294
8295    fn drop_role_with_epoch_inner(
8296        &self,
8297        name: &str,
8298        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8299    ) -> Result<Epoch> {
8300        let command = crate::catalog_cmds::CatalogCommand::DropRole {
8301            name: name.to_string(),
8302        };
8303        self.require(&crate::catalog_cmds::required_permission(&command))?;
8304        let _ddl = self.ddl_lock.lock();
8305        let _security_write = self.security_write()?;
8306        self.require(&crate::catalog_cmds::required_permission(&command))?;
8307        let _commit = self.commit_lock.lock();
8308        let epoch = self.epoch.bump_assigned();
8309        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8310        let mut next_catalog = self.catalog.read().clone();
8311        self.apply_catalog_command_to(&mut next_catalog, command)?;
8312        next_catalog.db_epoch = epoch.0;
8313        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8314        Ok(epoch)
8315    }
8316
8317    /// Grant a role to a user.
8318    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
8319        self.grant_role_with_epoch(username, role_name).map(|_| ())
8320    }
8321
8322    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8323        self.grant_role_with_epoch_inner(username, role_name, None)
8324    }
8325
8326    pub fn grant_role_with_epoch_controlled<F>(
8327        &self,
8328        username: &str,
8329        role_name: &str,
8330        mut before_publish: F,
8331    ) -> Result<Option<Epoch>>
8332    where
8333        F: FnMut() -> Result<()>,
8334    {
8335        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8336    }
8337
8338    fn grant_role_with_epoch_inner(
8339        &self,
8340        username: &str,
8341        role_name: &str,
8342        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8343    ) -> Result<Option<Epoch>> {
8344        let command = crate::catalog_cmds::CatalogCommand::GrantRole {
8345            username: username.to_string(),
8346            role: role_name.to_string(),
8347        };
8348        self.require(&crate::catalog_cmds::required_permission(&command))?;
8349        let _ddl = self.ddl_lock.lock();
8350        let _security_write = self.security_write()?;
8351        self.require(&crate::catalog_cmds::required_permission(&command))?;
8352        let _commit = self.commit_lock.lock();
8353        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8354        // short-circuit), so validation runs pure first and only real changes
8355        // are applied and recorded.
8356        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8357        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8358            return Ok(None);
8359        }
8360        let epoch = self.epoch.bump_assigned();
8361        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8362        let mut next_catalog = self.catalog.read().clone();
8363        self.apply_catalog_command_to(&mut next_catalog, command)?;
8364        next_catalog.db_epoch = epoch.0;
8365        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8366        Ok(Some(epoch))
8367    }
8368
8369    /// Revoke a role from a user.
8370    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
8371        self.revoke_role_with_epoch(username, role_name).map(|_| ())
8372    }
8373
8374    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8375        self.revoke_role_with_epoch_inner(username, role_name, None)
8376    }
8377
8378    pub fn revoke_role_with_epoch_controlled<F>(
8379        &self,
8380        username: &str,
8381        role_name: &str,
8382        mut before_publish: F,
8383    ) -> Result<Option<Epoch>>
8384    where
8385        F: FnMut() -> Result<()>,
8386    {
8387        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8388    }
8389
8390    fn revoke_role_with_epoch_inner(
8391        &self,
8392        username: &str,
8393        role_name: &str,
8394        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8395    ) -> Result<Option<Epoch>> {
8396        let command = crate::catalog_cmds::CatalogCommand::RevokeRole {
8397            username: username.to_string(),
8398            role: role_name.to_string(),
8399        };
8400        self.require(&crate::catalog_cmds::required_permission(&command))?;
8401        let _ddl = self.ddl_lock.lock();
8402        let _security_write = self.security_write()?;
8403        self.require(&crate::catalog_cmds::required_permission(&command))?;
8404        let _commit = self.commit_lock.lock();
8405        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8406        // short-circuit), so validation runs pure first and only real changes
8407        // are applied and recorded.
8408        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8409        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8410            return Ok(None);
8411        }
8412        let epoch = self.epoch.bump_assigned();
8413        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8414        let mut next_catalog = self.catalog.read().clone();
8415        self.apply_catalog_command_to(&mut next_catalog, command)?;
8416        next_catalog.db_epoch = epoch.0;
8417        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8418        Ok(Some(epoch))
8419    }
8420
8421    /// Grant a permission to a role.
8422    pub fn grant_permission(
8423        &self,
8424        role_name: &str,
8425        permission: crate::auth::Permission,
8426    ) -> Result<()> {
8427        self.grant_permission_with_epoch(role_name, permission)
8428            .map(|_| ())
8429    }
8430
8431    pub fn grant_permission_with_epoch(
8432        &self,
8433        role_name: &str,
8434        permission: crate::auth::Permission,
8435    ) -> Result<Option<Epoch>> {
8436        self.grant_permission_with_epoch_inner(role_name, permission, None)
8437    }
8438
8439    pub fn grant_permission_with_epoch_controlled<F>(
8440        &self,
8441        role_name: &str,
8442        permission: crate::auth::Permission,
8443        mut before_publish: F,
8444    ) -> Result<Option<Epoch>>
8445    where
8446        F: FnMut() -> Result<()>,
8447    {
8448        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8449    }
8450
8451    fn grant_permission_with_epoch_inner(
8452        &self,
8453        role_name: &str,
8454        permission: crate::auth::Permission,
8455        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8456    ) -> Result<Option<Epoch>> {
8457        let command = crate::catalog_cmds::CatalogCommand::GrantPermission {
8458            role: role_name.to_string(),
8459            permission,
8460        };
8461        self.require(&crate::catalog_cmds::required_permission(&command))?;
8462        let _ddl = self.ddl_lock.lock();
8463        let _security_write = self.security_write()?;
8464        self.require(&crate::catalog_cmds::required_permission(&command))?;
8465        let _commit = self.commit_lock.lock();
8466        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8467        // short-circuit), so validation runs pure first and only real changes
8468        // are applied and recorded.
8469        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8470        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8471            return Ok(None);
8472        }
8473        let epoch = self.epoch.bump_assigned();
8474        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8475        let mut next_catalog = self.catalog.read().clone();
8476        self.apply_catalog_command_to(&mut next_catalog, command)?;
8477        next_catalog.db_epoch = epoch.0;
8478        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8479        Ok(Some(epoch))
8480    }
8481
8482    /// Revoke a permission from a role.
8483    pub fn revoke_permission(
8484        &self,
8485        role_name: &str,
8486        permission: crate::auth::Permission,
8487    ) -> Result<()> {
8488        self.revoke_permission_with_epoch(role_name, permission)
8489            .map(|_| ())
8490    }
8491
8492    pub fn revoke_permission_with_epoch(
8493        &self,
8494        role_name: &str,
8495        permission: crate::auth::Permission,
8496    ) -> Result<Option<Epoch>> {
8497        self.revoke_permission_with_epoch_inner(role_name, permission, None)
8498    }
8499
8500    pub fn revoke_permission_with_epoch_controlled<F>(
8501        &self,
8502        role_name: &str,
8503        permission: crate::auth::Permission,
8504        mut before_publish: F,
8505    ) -> Result<Option<Epoch>>
8506    where
8507        F: FnMut() -> Result<()>,
8508    {
8509        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8510    }
8511
8512    fn revoke_permission_with_epoch_inner(
8513        &self,
8514        role_name: &str,
8515        permission: crate::auth::Permission,
8516        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8517    ) -> Result<Option<Epoch>> {
8518        let command = crate::catalog_cmds::CatalogCommand::RevokePermission {
8519            role: role_name.to_string(),
8520            permission,
8521        };
8522        self.require(&crate::catalog_cmds::required_permission(&command))?;
8523        let _ddl = self.ddl_lock.lock();
8524        let _security_write = self.security_write()?;
8525        self.require(&crate::catalog_cmds::required_permission(&command))?;
8526        let _commit = self.commit_lock.lock();
8527        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8528        // short-circuit), so validation runs pure first and only real changes
8529        // are applied and recorded.
8530        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8531        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8532            return Ok(None);
8533        }
8534        let epoch = self.epoch.bump_assigned();
8535        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8536        let mut next_catalog = self.catalog.read().clone();
8537        self.apply_catalog_command_to(&mut next_catalog, command)?;
8538        next_catalog.db_epoch = epoch.0;
8539        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8540        Ok(Some(epoch))
8541    }
8542
8543    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
8544    /// permissions from their roles. Returns `None` if the user doesn't exist.
8545    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
8546        let cat = self.catalog.read();
8547        Self::resolve_principal_from_catalog(&cat, username)
8548    }
8549
8550    /// Re-resolve only when the immutable user identity still exists. This is
8551    /// the server/session validation path; username reuse never matches.
8552    pub fn resolve_current_principal(
8553        &self,
8554        principal: &crate::auth::Principal,
8555    ) -> Option<crate::auth::Principal> {
8556        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
8557    }
8558
8559    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
8560    /// without needing a constructed `Database`. Used by the credentialed open
8561    /// path (which must verify credentials before the `Database` exists) and
8562    /// by [`resolve_principal`](Self::resolve_principal).
8563    fn resolve_principal_from_catalog(
8564        cat: &Catalog,
8565        username: &str,
8566    ) -> Option<crate::auth::Principal> {
8567        let user = cat.users.iter().find(|u| u.username == username)?;
8568        Self::resolve_user_principal_from_catalog(cat, user)
8569    }
8570
8571    fn resolve_bound_principal_from_catalog(
8572        cat: &Catalog,
8573        principal: &crate::auth::Principal,
8574    ) -> Option<crate::auth::Principal> {
8575        let user = cat.users.iter().find(|user| {
8576            user.id == principal.user_id
8577                && user.created_epoch == principal.created_epoch
8578                && user.username == principal.username
8579        })?;
8580        Self::resolve_user_principal_from_catalog(cat, user)
8581    }
8582
8583    fn resolve_user_principal_from_catalog(
8584        cat: &Catalog,
8585        user: &crate::auth::UserEntry,
8586    ) -> Option<crate::auth::Principal> {
8587        let mut permissions = Vec::new();
8588        for role_name in &user.roles {
8589            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
8590                permissions.extend(role.permissions.iter().cloned());
8591            }
8592        }
8593        Some(crate::auth::Principal {
8594            user_id: user.id,
8595            created_epoch: user.created_epoch,
8596            username: user.username.clone(),
8597            is_admin: user.is_admin,
8598            roles: user.roles.clone(),
8599            permissions,
8600        })
8601    }
8602
8603    /// Check whether a user has a specific permission (via their roles).
8604    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
8605        match self.resolve_principal(username) {
8606            Some(p) => p.has_permission(permission),
8607            None => false,
8608        }
8609    }
8610
8611    /// Returns `true` if this database's catalog has `require_auth = true`.
8612    /// When true, every operation consults the cached [`Principal`] via
8613    /// [`require`](Self::require).
8614    pub fn require_auth_enabled(&self) -> bool {
8615        self.catalog.read().require_auth
8616    }
8617
8618    /// A snapshot of the cached principal for this handle, if any. `None` for
8619    /// databases opened without credentials (the default). Returns a clone
8620    /// because the principal lives behind an `RwLock`.
8621    pub fn principal(&self) -> Option<crate::auth::Principal> {
8622        self.principal.read().clone()
8623    }
8624
8625    /// Build a `TableAuthChecker` from the current auth state. Used when
8626    /// mounting a new table (`create_table`) so the table inherits the
8627    /// database's enforcement configuration. The checker reads the live
8628    /// `require_auth` flag and cached principal, so changes via `enable_auth`
8629    /// / `refresh_principal` propagate to already-mounted tables.
8630    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
8631        if self.shared {
8632            return None;
8633        }
8634        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
8635            self.auth_state.clone(),
8636        )))
8637    }
8638
8639    /// Re-resolve the cached principal from the shared current catalog.
8640    /// Long-lived
8641    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
8642    /// possibly made by a different handle to the same database — to pick up
8643    /// the new effective permissions without re-verifying the password.
8644    ///
8645    /// The process-wide security version reloads from disk only when another
8646    /// handle published a newer catalog. The username is taken from
8647    /// the existing cached principal; if the user has since been dropped,
8648    /// returns [`MongrelError::InvalidCredentials`].
8649    ///
8650    /// No-op (returns `Ok(())`) on a credentialless database, or on a
8651    /// credentialed database whose cached principal is `None`.
8652    pub fn refresh_principal(&self) -> Result<()> {
8653        let previous = match self.principal.read().clone() {
8654            Some(principal) => principal,
8655            None => return Ok(()),
8656        };
8657        // Service principals are runtime identities, not catalog users. Their
8658        // immutable scopes are stored on the handle and never re-resolved by
8659        // username.
8660        if previous.user_id == 0 {
8661            return Ok(());
8662        }
8663        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
8664        self.refresh_security_catalog_if_stale(observed_version)?;
8665        let cat = self.catalog.read();
8666        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
8667            Some(p) => {
8668                *self.principal.write() = Some(p.clone());
8669                // Update the shared auth state so mounted Tables see the new
8670                // permissions immediately (Tables read from AuthState, not from
8671                // self.principal).
8672                self.auth_state.set_principal(Some(p));
8673                Ok(())
8674            }
8675            None => Err(MongrelError::InvalidCredentials {
8676                username: previous.username,
8677            }),
8678        }
8679    }
8680
8681    /// Number of security-catalog disk reloads performed by this open handle.
8682    /// Initial open reads are excluded.
8683    pub fn security_catalog_disk_read_count(&self) -> u64 {
8684        self.security_catalog_disk_reads.load(Ordering::Relaxed)
8685    }
8686
8687    /// Convert a credentialless database to a credentialed one: create the
8688    /// first admin user, set `require_auth = true`, and cache the admin
8689    /// principal on this handle so subsequent operations on the same handle
8690    /// continue to work. After this call, the database can only be reopened
8691    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
8692    ///
8693    /// Refuses if the database already has `require_auth = true`. This is
8694    /// the conversion path for existing databases; for fresh databases,
8695    /// `create_with_credentials` sets everything up atomically.
8696    ///
8697    /// See `docs/15-credential-enforcement.md`.
8698    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
8699        if self.shared {
8700            // Fail closed (spec §4.6): one shared handle must not flip the
8701            // core into an enforcement mode the other handles cannot observe.
8702            // Stage 1A shared cores stay credentialless; auth-mode transitions
8703            // require an exclusive `Database` owner. Per-handle principals
8704            // land with Stage 1D sessions.
8705            return Err(MongrelError::Conflict(
8706                "enable_auth requires an exclusive Database owner; shared cores reject \
8707                 auth-mode transitions"
8708                    .into(),
8709            ));
8710        }
8711        let password_hash =
8712            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
8713        let scram_sha_256 =
8714            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
8715        let mysql_caching_sha2 =
8716            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
8717        let _ddl = self.ddl_lock.lock();
8718        let _security_write = self.security_write()?;
8719        let _commit = self.commit_lock.lock();
8720        let epoch = self.epoch.bump_assigned();
8721        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8722        let mut next_catalog = self.catalog.read().clone();
8723        if next_catalog.require_auth {
8724            return Err(MongrelError::InvalidArgument(
8725                "database already has require_auth enabled".into(),
8726            ));
8727        }
8728        if next_catalog
8729            .users
8730            .iter()
8731            .any(|u| u.username == admin_username)
8732        {
8733            return Err(MongrelError::InvalidArgument(format!(
8734                "user {admin_username:?} already exists"
8735            )));
8736        }
8737        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
8738        let id = next_catalog.next_user_id;
8739        next_catalog.next_user_id = id
8740            .checked_add(1)
8741            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
8742        next_catalog.users.push(crate::auth::UserEntry {
8743            id,
8744            username: admin_username.to_string(),
8745            password_hash,
8746            scram_sha_256: Some(scram_sha_256),
8747            mysql_caching_sha2: Some(mysql_caching_sha2),
8748            roles: Vec::new(),
8749            is_admin: true,
8750            created_epoch: epoch.0,
8751        });
8752        next_catalog.require_auth = true;
8753        advance_security_version(&mut next_catalog)?;
8754        next_catalog.db_epoch = epoch.0;
8755        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
8756        // Cache the admin principal on this handle + update the shared auth
8757        // state whenever rename published, even if directory fsync was
8758        // inconclusive.
8759        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
8760            let principal = crate::auth::Principal {
8761                user_id: id,
8762                created_epoch: epoch.0,
8763                username: admin_username.to_string(),
8764                is_admin: true,
8765                roles: Vec::new(),
8766                permissions: Vec::new(),
8767            };
8768            *self.principal.write() = Some(principal.clone());
8769            self.auth_state.set_principal(Some(principal));
8770        }
8771        publish
8772    }
8773
8774    /// Disable `require_auth` on this database, reverting it to credentialless
8775    /// mode. This is the **recovery** path — it requires the handle to already
8776    /// be open (and therefore already authenticated if `require_auth` was on).
8777    ///
8778    /// After this call, the database can be reopened with plain
8779    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
8780    /// credentials. All existing users and roles are preserved in the catalog
8781    /// (so `require_auth` can be re-enabled without recreating them), but they
8782    /// are no longer consulted for enforcement.
8783    ///
8784    /// For true **offline** recovery (when credentials are lost and no
8785    /// authenticated handle is available), the caller opens the database
8786    /// directly via the catalog file (filesystem access required) and calls
8787    /// this method — see the CLI's `auth disable-offline` command.
8788    ///
8789    /// See `docs/15-credential-enforcement.md` §4.7.
8790    pub fn disable_auth(&self) -> Result<()> {
8791        let _ddl = self.ddl_lock.lock();
8792        let _security_write = self.security_write()?;
8793        let _commit = self.commit_lock.lock();
8794        let epoch = self.epoch.bump_assigned();
8795        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8796        let mut next_catalog = self.catalog.read().clone();
8797        if !next_catalog.require_auth {
8798            return Err(MongrelError::InvalidArgument(
8799                "database does not have require_auth enabled".into(),
8800            ));
8801        }
8802        next_catalog.require_auth = false;
8803        advance_security_version(&mut next_catalog)?;
8804        next_catalog.db_epoch = epoch.0;
8805        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
8806        // Clear the cached principal — enforcement is now off.
8807        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
8808            *self.principal.write() = None;
8809        }
8810        publish
8811    }
8812
8813    /// Enforcement check: if the catalog has `require_auth = true`, verify
8814    /// that the cached principal satisfies `perm`. Called by every
8815    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
8816    /// Table/Transaction/MongrelSession operations).
8817    ///
8818    /// On a credentialless database this is a no-op (`Ok(())`).
8819    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
8820        self.ensure_owner_process()?;
8821        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
8822            return Err(MongrelError::ReadOnlyReplica);
8823        }
8824        if self.principal.read().is_some() {
8825            self.refresh_principal().map_err(|error| match error {
8826                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
8827                error => error,
8828            })?;
8829        }
8830        if !self.catalog.read().require_auth {
8831            return Ok(());
8832        }
8833        let guard = self.principal.read();
8834        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
8835        if p.has_permission(perm) {
8836            Ok(())
8837        } else {
8838            Err(MongrelError::PermissionDenied {
8839                required: perm.clone(),
8840                principal: p.username.clone(),
8841            })
8842        }
8843    }
8844
8845    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
8846    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
8847    /// other callers that know the operation kind + table name but don't want
8848    /// to construct the full `Permission` enum value themselves.
8849    pub fn require_table(
8850        &self,
8851        table: &str,
8852        perm: crate::auth_state::RequiredPermission,
8853    ) -> Result<()> {
8854        self.require(&perm.into_permission(table))
8855    }
8856
8857    pub fn triggers(&self) -> Vec<StoredTrigger> {
8858        self.catalog
8859            .read()
8860            .triggers
8861            .iter()
8862            .map(|t| t.trigger.clone())
8863            .collect()
8864    }
8865
8866    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
8867        self.catalog
8868            .read()
8869            .triggers
8870            .iter()
8871            .find(|t| t.trigger.name == name)
8872            .map(|t| t.trigger.clone())
8873    }
8874
8875    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
8876        self.create_trigger_inner(trigger, None, None)
8877    }
8878
8879    pub fn create_trigger_controlled<F>(
8880        &self,
8881        trigger: StoredTrigger,
8882        mut before_publish: F,
8883    ) -> Result<StoredTrigger>
8884    where
8885        F: FnMut() -> Result<()>,
8886    {
8887        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
8888    }
8889
8890    pub fn create_trigger_as_controlled<F>(
8891        &self,
8892        trigger: StoredTrigger,
8893        principal: Option<&crate::auth::Principal>,
8894        mut before_publish: F,
8895    ) -> Result<StoredTrigger>
8896    where
8897        F: FnMut() -> Result<()>,
8898    {
8899        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
8900    }
8901
8902    fn create_trigger_inner(
8903        &self,
8904        mut trigger: StoredTrigger,
8905        principal: Option<&crate::auth::Principal>,
8906        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8907    ) -> Result<StoredTrigger> {
8908        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
8909            trigger: trigger.clone(),
8910        };
8911        self.require_for(
8912            principal,
8913            &crate::catalog_cmds::required_permission(&command),
8914        )?;
8915        let _g = self.ddl_lock.lock();
8916        let _security_write = self.security_write()?;
8917        self.require_for(
8918            principal,
8919            &crate::catalog_cmds::required_permission(&command),
8920        )?;
8921        trigger.validate()?;
8922        self.validate_trigger_references(&trigger)
8923            .map_err(trigger_validation_error)?;
8924        // S1F-001: validation runs pure against the current catalog first so
8925        // failures (duplicate name, unknown target) surface before an epoch is
8926        // consumed, exactly like the legacy pre-bump checks.
8927        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8928        let commit_lock = Arc::clone(&self.commit_lock);
8929        let _c = commit_lock.lock();
8930        let epoch = self.epoch.bump_assigned();
8931        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8932        trigger.created_epoch = epoch.0;
8933        trigger.updated_epoch = epoch.0;
8934        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
8935            trigger: trigger.clone(),
8936        };
8937        let mut next_catalog = self.catalog.read().clone();
8938        self.apply_catalog_command_to(&mut next_catalog, command)?;
8939        next_catalog.db_epoch = epoch.0;
8940        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8941        Ok(trigger)
8942    }
8943
8944    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
8945        self.create_or_replace_trigger_inner(trigger, None, None)
8946    }
8947
8948    pub fn create_or_replace_trigger_controlled<F>(
8949        &self,
8950        trigger: StoredTrigger,
8951        mut before_publish: F,
8952    ) -> Result<StoredTrigger>
8953    where
8954        F: FnMut() -> Result<()>,
8955    {
8956        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
8957    }
8958
8959    pub fn create_or_replace_trigger_as_controlled<F>(
8960        &self,
8961        trigger: StoredTrigger,
8962        principal: Option<&crate::auth::Principal>,
8963        mut before_publish: F,
8964    ) -> Result<StoredTrigger>
8965    where
8966        F: FnMut() -> Result<()>,
8967    {
8968        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
8969    }
8970
8971    fn create_or_replace_trigger_inner(
8972        &self,
8973        trigger: StoredTrigger,
8974        principal: Option<&crate::auth::Principal>,
8975        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8976    ) -> Result<StoredTrigger> {
8977        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
8978            trigger: trigger.clone(),
8979        };
8980        self.require_for(
8981            principal,
8982            &crate::catalog_cmds::required_permission(&command),
8983        )?;
8984        let _g = self.ddl_lock.lock();
8985        let _security_write = self.security_write()?;
8986        self.require_for(
8987            principal,
8988            &crate::catalog_cmds::required_permission(&command),
8989        )?;
8990        trigger.validate()?;
8991        self.validate_trigger_references(&trigger)
8992            .map_err(trigger_validation_error)?;
8993        // S1F-001: validation runs pure against the current catalog first so
8994        // structural failures surface before an epoch is consumed, exactly
8995        // like the legacy pre-bump checks.
8996        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8997        let commit_lock = Arc::clone(&self.commit_lock);
8998        let _c = commit_lock.lock();
8999        let epoch = self.epoch.bump_assigned();
9000        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9001        let mut next_catalog = self.catalog.read().clone();
9002        // Resolve the replacement image against the candidate catalog: the
9003        // engine stamps version/epochs from the commit epoch (preserving the
9004        // original created_epoch on replacement), then the command record
9005        // carries that resolved image.
9006        let replaced = match next_catalog
9007            .triggers
9008            .iter()
9009            .position(|t| t.trigger.name == trigger.name)
9010        {
9011            Some(idx) => next_catalog.triggers[idx]
9012                .trigger
9013                .replaced(trigger.clone(), epoch.0)?,
9014            None => {
9015                let mut next = trigger;
9016                next.created_epoch = epoch.0;
9017                next.updated_epoch = epoch.0;
9018                next
9019            }
9020        };
9021        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9022            trigger: replaced.clone(),
9023        };
9024        self.apply_catalog_command_to(&mut next_catalog, command)?;
9025        next_catalog.db_epoch = epoch.0;
9026        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9027        Ok(replaced)
9028    }
9029
9030    pub fn drop_trigger(&self, name: &str) -> Result<()> {
9031        self.drop_trigger_with_epoch(name).map(|_| ())
9032    }
9033
9034    /// Drop one trigger and return the exact catalog publication epoch.
9035    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
9036        self.drop_triggers_with_epoch(&[name.to_string()])
9037    }
9038
9039    pub fn drop_trigger_with_epoch_controlled<F>(
9040        &self,
9041        name: &str,
9042        before_publish: F,
9043    ) -> Result<Epoch>
9044    where
9045        F: FnMut() -> Result<()>,
9046    {
9047        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
9048    }
9049
9050    /// Atomically drop several triggers in one catalog publication.
9051    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
9052        self.drop_triggers_with_epoch_inner(names, None, None)
9053    }
9054
9055    pub fn drop_triggers_with_epoch_controlled<F>(
9056        &self,
9057        names: &[String],
9058        mut before_publish: F,
9059    ) -> Result<Epoch>
9060    where
9061        F: FnMut() -> Result<()>,
9062    {
9063        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
9064    }
9065
9066    pub fn drop_triggers_with_epoch_as_controlled<F>(
9067        &self,
9068        names: &[String],
9069        principal: Option<&crate::auth::Principal>,
9070        mut before_publish: F,
9071    ) -> Result<Epoch>
9072    where
9073        F: FnMut() -> Result<()>,
9074    {
9075        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
9076    }
9077
9078    fn drop_triggers_with_epoch_inner(
9079        &self,
9080        names: &[String],
9081        principal: Option<&crate::auth::Principal>,
9082        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9083    ) -> Result<Epoch> {
9084        if names.is_empty() {
9085            return Err(MongrelError::InvalidArgument(
9086                "at least one trigger name is required".into(),
9087            ));
9088        }
9089        let commands: Vec<crate::catalog_cmds::CatalogCommand> = names
9090            .iter()
9091            .map(|name| crate::catalog_cmds::CatalogCommand::DropTrigger { name: name.clone() })
9092            .collect();
9093        for command in &commands {
9094            self.require_for(
9095                principal,
9096                &crate::catalog_cmds::required_permission(command),
9097            )?;
9098        }
9099        let _g = self.ddl_lock.lock();
9100        let _security_write = self.security_write()?;
9101        for command in &commands {
9102            self.require_for(
9103                principal,
9104                &crate::catalog_cmds::required_permission(command),
9105            )?;
9106        }
9107        // S1F-001: every name's command validates pure against the current
9108        // catalog first so a missing trigger surfaces before an epoch is
9109        // consumed, exactly like the legacy pre-bump check. A batch drop then
9110        // records one command per name (the emitting-layer expansion rule).
9111        {
9112            let cat = self.catalog.read();
9113            for command in &commands {
9114                crate::catalog_cmds::apply(&cat, command)?;
9115            }
9116        }
9117        let commit_lock = Arc::clone(&self.commit_lock);
9118        let _c = commit_lock.lock();
9119        let epoch = self.epoch.bump_assigned();
9120        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9121        let mut next_catalog = self.catalog.read().clone();
9122        for command in commands {
9123            self.apply_catalog_command_to(&mut next_catalog, command)?;
9124        }
9125        next_catalog.db_epoch = epoch.0;
9126        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9127        Ok(epoch)
9128    }
9129
9130    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
9131        self.catalog.read().external_tables.clone()
9132    }
9133
9134    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
9135        self.catalog
9136            .read()
9137            .external_tables
9138            .iter()
9139            .find(|t| t.name == name)
9140            .cloned()
9141    }
9142
9143    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
9144        self.create_external_table_inner(entry, None)
9145    }
9146
9147    pub fn create_external_table_controlled<F>(
9148        &self,
9149        entry: ExternalTableEntry,
9150        mut before_publish: F,
9151    ) -> Result<ExternalTableEntry>
9152    where
9153        F: FnMut() -> Result<()>,
9154    {
9155        self.create_external_table_inner(entry, Some(&mut before_publish))
9156    }
9157
9158    fn create_external_table_inner(
9159        &self,
9160        mut entry: ExternalTableEntry,
9161        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9162    ) -> Result<ExternalTableEntry> {
9163        self.require(&crate::auth::Permission::Ddl)?;
9164        let _g = self.ddl_lock.lock();
9165        let _security_write = self.security_write()?;
9166        self.require(&crate::auth::Permission::Ddl)?;
9167        entry.validate()?;
9168        {
9169            let cat = self.catalog.read();
9170            if cat.live(&entry.name).is_some()
9171                || cat.external_tables.iter().any(|t| t.name == entry.name)
9172            {
9173                return Err(MongrelError::InvalidArgument(format!(
9174                    "table {:?} already exists",
9175                    entry.name
9176                )));
9177            }
9178        }
9179        let commit_lock = Arc::clone(&self.commit_lock);
9180        let _c = commit_lock.lock();
9181        // A prior durable drop may have left connector state behind if its
9182        // cleanup failed or the process crashed. Never let a new table with
9183        // the same name inherit that stale state.
9184        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
9185        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
9186        let epoch = self.epoch.bump_assigned();
9187        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9188        entry.created_epoch = epoch.0;
9189        let mut next_catalog = self.catalog.read().clone();
9190        next_catalog.external_tables.push(entry.clone());
9191        next_catalog.db_epoch = epoch.0;
9192        self.publish_catalog_candidate_with_prelude(
9193            next_catalog,
9194            epoch,
9195            &mut _epoch_guard,
9196            before_publish,
9197            vec![(
9198                EXTERNAL_TABLE_ID,
9199                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9200                    name: entry.name.clone(),
9201                    generation_epoch: epoch.0,
9202                }),
9203            )],
9204        )?;
9205        Ok(entry)
9206    }
9207
9208    pub fn drop_external_table(&self, name: &str) -> Result<()> {
9209        self.drop_external_table_with_epoch(name).map(|_| ())
9210    }
9211
9212    /// Drop an external table and return the exact catalog publication epoch.
9213    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
9214        self.drop_external_table_with_epoch_inner(name, None)
9215    }
9216
9217    pub fn drop_external_table_with_epoch_controlled<F>(
9218        &self,
9219        name: &str,
9220        mut before_publish: F,
9221    ) -> Result<Epoch>
9222    where
9223        F: FnMut() -> Result<()>,
9224    {
9225        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
9226    }
9227
9228    fn drop_external_table_with_epoch_inner(
9229        &self,
9230        name: &str,
9231        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9232    ) -> Result<Epoch> {
9233        self.require(&crate::auth::Permission::Ddl)?;
9234        let _g = self.ddl_lock.lock();
9235        let _security_write = self.security_write()?;
9236        self.require(&crate::auth::Permission::Ddl)?;
9237        let commit_lock = Arc::clone(&self.commit_lock);
9238        let _c = commit_lock.lock();
9239        let epoch = self.epoch.bump_assigned();
9240        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9241        let mut next_catalog = self.catalog.read().clone();
9242        let before = next_catalog.external_tables.len();
9243        next_catalog.external_tables.retain(|t| t.name != name);
9244        if next_catalog.external_tables.len() == before {
9245            return Err(MongrelError::NotFound(format!(
9246                "external table {name:?} not found"
9247            )));
9248        }
9249        next_catalog.db_epoch = epoch.0;
9250        self.publish_catalog_candidate_with_prelude(
9251            next_catalog,
9252            epoch,
9253            &mut _epoch_guard,
9254            before_publish,
9255            vec![(
9256                EXTERNAL_TABLE_ID,
9257                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9258                    name: name.to_string(),
9259                    generation_epoch: epoch.0,
9260                }),
9261            )],
9262        )?;
9263        let state_dir = self.root.join(VTAB_DIR).join(name);
9264        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
9265            return Err(MongrelError::DurableCommit {
9266                epoch: epoch.0,
9267                message: format!(
9268                    "external table was dropped but connector-state cleanup failed: {error}"
9269                ),
9270            });
9271        }
9272        Ok(epoch)
9273    }
9274
9275    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
9276        let txn_id = self.alloc_txn_id()?;
9277        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9278        self.commit_transaction_with_external_states(
9279            txn_id,
9280            self.epoch.visible(),
9281            Vec::new(),
9282            vec![(name.to_string(), state.to_vec())],
9283            Vec::new(),
9284            principal,
9285            catalog_bound,
9286            None,
9287            crate::txn::TxnCommitContext::internal(),
9288        )
9289        .map(|(epoch, _)| epoch)
9290    }
9291
9292    pub fn trigger_config(&self) -> TriggerConfig {
9293        use std::sync::atomic::Ordering;
9294        TriggerConfig {
9295            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
9296            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
9297            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
9298        }
9299    }
9300
9301    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
9302        use std::sync::atomic::Ordering;
9303        if config.max_depth == 0 {
9304            return Err(MongrelError::InvalidArgument(
9305                "trigger max_depth must be greater than 0".into(),
9306            ));
9307        }
9308        self.trigger_recursive
9309            .store(config.recursive_triggers, Ordering::Relaxed);
9310        self.trigger_max_depth
9311            .store(config.max_depth, Ordering::Relaxed);
9312        self.trigger_max_loop_iterations
9313            .store(config.max_loop_iterations, Ordering::Relaxed);
9314        Ok(())
9315    }
9316
9317    pub fn set_recursive_triggers(&self, recursive: bool) {
9318        use std::sync::atomic::Ordering;
9319        self.trigger_recursive.store(recursive, Ordering::Relaxed);
9320    }
9321
9322    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
9323    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
9324    /// as a low-latency wake-up.
9325    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
9326        self.notify.subscribe()
9327    }
9328
9329    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
9330        self.change_wake.subscribe()
9331    }
9332
9333    /// Reconstruct committed row changes from the retained shared WAL. Event
9334    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
9335    /// resumes before the oldest retained commit receives `gap = true` and
9336    /// must rebootstrap instead of silently skipping changes.
9337    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
9338        let control = crate::ExecutionControl::new(None);
9339        self.change_events_since_controlled(last_event_id, &control)
9340    }
9341
9342    /// Reconstruct committed changes with cooperative cancellation and bounds.
9343    pub fn change_events_since_controlled(
9344        &self,
9345        last_event_id: Option<&str>,
9346        control: &crate::ExecutionControl,
9347    ) -> Result<CdcBatch> {
9348        use crate::wal::Op;
9349
9350        control.checkpoint()?;
9351        let resume = match last_event_id {
9352            Some(id) => {
9353                let (epoch, index) = id.split_once(':').ok_or_else(|| {
9354                    MongrelError::InvalidArgument(format!(
9355                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
9356                    ))
9357                })?;
9358                Some((
9359                    epoch.parse::<u64>().map_err(|error| {
9360                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
9361                    })?,
9362                    index.parse::<u32>().map_err(|error| {
9363                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
9364                    })?,
9365                ))
9366            }
9367            None => None,
9368        };
9369
9370        let mut wal = self.shared_wal.lock();
9371        wal.group_sync()?;
9372        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
9373        let records = crate::wal::SharedWal::replay_with_dek_controlled(
9374            &self.root,
9375            wal_dek.as_ref(),
9376            control,
9377            CDC_MAX_WAL_RECORDS,
9378            CDC_MAX_WAL_REPLAY_BYTES,
9379        )?;
9380        drop(wal);
9381        control.checkpoint()?;
9382
9383        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
9384        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
9385        for (index, record) in records.iter().enumerate() {
9386            if index % 256 == 0 {
9387                control.checkpoint()?;
9388            }
9389            if let Op::TxnCommit { epoch, added_runs } = &record.op {
9390                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
9391            }
9392            if let Op::SpilledRows { table_id, rows } = &record.op {
9393                spilled_payloads
9394                    .entry((record.txn_id, *table_id))
9395                    .or_default()
9396                    .push(rows);
9397            }
9398        }
9399        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
9400        let current_epoch = self.epoch.committed().0;
9401        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
9402        let gap = resume.is_some_and(|(epoch, _)| {
9403            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
9404        });
9405        if gap {
9406            return Ok(CdcBatch {
9407                events: Vec::new(),
9408                current_epoch,
9409                earliest_epoch,
9410                gap: true,
9411            });
9412        }
9413
9414        let table_names: HashMap<u64, String> = self
9415            .catalog
9416            .read()
9417            .tables
9418            .iter()
9419            .map(|entry| (entry.table_id, entry.name.clone()))
9420            .collect();
9421        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
9422        let mut retained_bytes = 0_usize;
9423        for (index, record) in records.iter().enumerate() {
9424            if index % 256 == 0 {
9425                control.checkpoint()?;
9426            }
9427            if !commits.contains_key(&record.txn_id) {
9428                continue;
9429            }
9430            let Op::BeforeImage {
9431                table_id,
9432                row_id,
9433                row,
9434            } = &record.op
9435            else {
9436                continue;
9437            };
9438            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9439                return Err(MongrelError::ResourceLimitExceeded {
9440                    resource: "CDC before-image bytes",
9441                    requested: row.len(),
9442                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9443                });
9444            }
9445            let before: crate::memtable::Row = bincode::deserialize(row)?;
9446            if before_images.len() >= CDC_MAX_ROWS {
9447                return Err(MongrelError::ResourceLimitExceeded {
9448                    resource: "CDC before-image rows",
9449                    requested: before_images.len().saturating_add(1),
9450                    limit: CDC_MAX_ROWS,
9451                });
9452            }
9453            charge_cdc_bytes(
9454                &mut retained_bytes,
9455                cdc_row_storage_bytes(&before),
9456                "CDC retained bytes",
9457            )?;
9458            before_images.insert((record.txn_id, *table_id, row_id.0), before);
9459        }
9460        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
9461        let mut events = Vec::new();
9462        let mut decoded_rows = before_images.len();
9463        for (record_index, record) in records.iter().enumerate() {
9464            if record_index % 256 == 0 {
9465                control.checkpoint()?;
9466            }
9467            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
9468                continue;
9469            };
9470            let event = match &record.op {
9471                Op::Put { table_id, rows } => {
9472                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9473                        return Err(MongrelError::ResourceLimitExceeded {
9474                            resource: "CDC inline row bytes",
9475                            requested: rows.len(),
9476                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9477                        });
9478                    }
9479                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
9480                    decoded_rows = decoded_rows.saturating_add(rows.len());
9481                    if decoded_rows > CDC_MAX_ROWS {
9482                        return Err(MongrelError::ResourceLimitExceeded {
9483                            resource: "CDC decoded rows",
9484                            requested: decoded_rows,
9485                            limit: CDC_MAX_ROWS,
9486                        });
9487                    }
9488                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
9489                    let mut peak_bytes = retained_bytes;
9490                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9491                    let data = serde_json::to_value(rows)
9492                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
9493                    Some((*table_id, "put", data, event_bytes))
9494                }
9495                Op::Delete { table_id, row_ids } => {
9496                    let before = row_ids
9497                        .iter()
9498                        .filter_map(|row_id| {
9499                            before_images
9500                                .get(&(record.txn_id, *table_id, row_id.0))
9501                                .cloned()
9502                        })
9503                        .collect::<Vec<_>>();
9504                    let event_bytes = cdc_rows_json_bytes(&before)
9505                        .saturating_add(
9506                            row_ids
9507                                .len()
9508                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
9509                        )
9510                        .saturating_add(512);
9511                    let mut peak_bytes = retained_bytes;
9512                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9513                    Some((
9514                        *table_id,
9515                        "delete",
9516                        serde_json::json!({
9517                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
9518                            "before": before,
9519                        }),
9520                        event_bytes,
9521                    ))
9522                }
9523                Op::TruncateTable { table_id } => {
9524                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
9525                }
9526                _ => None,
9527            };
9528            if let Some((table_id, op, data, event_bytes)) = event {
9529                let index = operation_indices.entry(record.txn_id).or_insert(0);
9530                let event_position = (*commit_epoch, *index);
9531                *index = index.saturating_add(1);
9532                if resume.is_some_and(|position| event_position <= position) {
9533                    continue;
9534                }
9535                if events.len() >= CDC_MAX_EVENTS {
9536                    return Err(MongrelError::ResourceLimitExceeded {
9537                        resource: "CDC events",
9538                        requested: events.len().saturating_add(1),
9539                        limit: CDC_MAX_EVENTS,
9540                    });
9541                }
9542                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
9543                events.push(ChangeEvent {
9544                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
9545                    channel: "changes".into(),
9546                    table_id: Some(table_id),
9547                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
9548                    op: op.into(),
9549                    epoch: *commit_epoch,
9550                    txn_id: Some(record.txn_id),
9551                    message: None,
9552                    data: Some(data),
9553                });
9554            }
9555            if let Op::TxnCommit { added_runs, .. } = &record.op {
9556                for run in added_runs {
9557                    control.checkpoint()?;
9558                    let index = operation_indices.entry(record.txn_id).or_insert(0);
9559                    let event_position = (*commit_epoch, *index);
9560                    *index = index.saturating_add(1);
9561                    if resume.is_some_and(|position| event_position <= position) {
9562                        continue;
9563                    }
9564                    let mut rows = if let Some(payloads) =
9565                        spilled_payloads.get(&(record.txn_id, run.table_id))
9566                    {
9567                        let mut rows = Vec::new();
9568                        for payload in payloads {
9569                            control.checkpoint()?;
9570                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9571                                return Err(MongrelError::ResourceLimitExceeded {
9572                                    resource: "CDC spilled row bytes",
9573                                    requested: payload.len(),
9574                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9575                                });
9576                            }
9577                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
9578                            if decoded_rows
9579                                .saturating_add(rows.len())
9580                                .saturating_add(chunk.len())
9581                                > CDC_MAX_ROWS
9582                            {
9583                                return Err(MongrelError::ResourceLimitExceeded {
9584                                    resource: "CDC decoded rows",
9585                                    requested: decoded_rows
9586                                        .saturating_add(rows.len())
9587                                        .saturating_add(chunk.len()),
9588                                    limit: CDC_MAX_ROWS,
9589                                });
9590                            }
9591                            rows.extend(chunk);
9592                        }
9593                        rows
9594                    } else {
9595                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
9596                            return Ok(CdcBatch {
9597                                events: Vec::new(),
9598                                current_epoch,
9599                                earliest_epoch,
9600                                gap: true,
9601                            });
9602                        };
9603                        let table = handle.lock();
9604                        let mut reader = match table.open_reader(run.run_id) {
9605                            Ok(reader) => reader,
9606                            Err(_) => {
9607                                return Ok(CdcBatch {
9608                                    events: Vec::new(),
9609                                    current_epoch,
9610                                    earliest_epoch,
9611                                    gap: true,
9612                                })
9613                            }
9614                        };
9615                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
9616                        let rows = reader.all_rows_controlled(control, remaining)?;
9617                        drop(reader);
9618                        drop(table);
9619                        rows
9620                    };
9621                    for row in &mut rows {
9622                        row.committed_epoch = Epoch(*commit_epoch);
9623                    }
9624                    decoded_rows = decoded_rows.saturating_add(rows.len());
9625                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
9626                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
9627                    if events.len() >= CDC_MAX_EVENTS {
9628                        return Err(MongrelError::ResourceLimitExceeded {
9629                            resource: "CDC events",
9630                            requested: events.len().saturating_add(1),
9631                            limit: CDC_MAX_EVENTS,
9632                        });
9633                    }
9634                    events.push(ChangeEvent {
9635                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
9636                        channel: "changes".into(),
9637                        table_id: Some(run.table_id),
9638                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
9639                        op: "put_run".into(),
9640                        epoch: *commit_epoch,
9641                        txn_id: Some(record.txn_id),
9642                        message: None,
9643                        data: Some(serde_json::json!({
9644                            "run_id": run.run_id.to_string(),
9645                            "row_count": run.row_count,
9646                            "min_row_id": run.min_row_id,
9647                            "max_row_id": run.max_row_id,
9648                            "rows": rows,
9649                        })),
9650                    });
9651                }
9652            }
9653        }
9654        control.checkpoint()?;
9655        Ok(CdcBatch {
9656            events,
9657            current_epoch,
9658            earliest_epoch,
9659            gap: false,
9660        })
9661    }
9662
9663    /// Publish a notification message on a named channel. Reaches all active
9664    /// subscribers (daemon `/events`, application listeners).
9665    pub fn notify(&self, channel: &str, message: Option<String>) {
9666        let _ = self.notify.send(ChangeEvent {
9667            id: None,
9668            channel: channel.to_string(),
9669            table_id: None,
9670            table: String::new(),
9671            op: "notify".into(),
9672            epoch: self.epoch.visible().0,
9673            txn_id: None,
9674            message,
9675            data: None,
9676        });
9677    }
9678
9679    pub fn call_procedure(
9680        &self,
9681        name: &str,
9682        args: HashMap<String, crate::Value>,
9683    ) -> Result<ProcedureCallResult> {
9684        self.call_procedure_as(name, args, None)
9685    }
9686
9687    pub fn call_procedure_as(
9688        &self,
9689        name: &str,
9690        args: HashMap<String, crate::Value>,
9691        principal: Option<&crate::auth::Principal>,
9692    ) -> Result<ProcedureCallResult> {
9693        let control = crate::ExecutionControl::new(None);
9694        self.call_procedure_as_controlled(name, args, principal, &control, || true)
9695    }
9696
9697    /// Execute only the exact procedure revision previously authorized by the
9698    /// caller. A dropped or replaced definition fails closed.
9699    #[doc(hidden)]
9700    pub fn call_procedure_as_bound(
9701        &self,
9702        expected: &StoredProcedure,
9703        args: HashMap<String, crate::Value>,
9704        principal: Option<&crate::auth::Principal>,
9705    ) -> Result<ProcedureCallResult> {
9706        self.require_for(principal, &crate::auth::Permission::All)?;
9707        let procedure = self.procedure(&expected.name).ok_or_else(|| {
9708            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
9709        })?;
9710        if &procedure != expected {
9711            return Err(MongrelError::Conflict(format!(
9712                "procedure {:?} changed after request authorization",
9713                expected.name
9714            )));
9715        }
9716        let control = crate::ExecutionControl::new(None);
9717        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
9718    }
9719
9720    /// Execute a procedure with cooperative cancellation during preparation.
9721    /// `before_commit` runs after every procedure step has succeeded and
9722    /// immediately before a write procedure commits. Returning `false` aborts
9723    /// the transaction without publishing it.
9724    #[doc(hidden)]
9725    pub fn call_procedure_as_controlled<F>(
9726        &self,
9727        name: &str,
9728        args: HashMap<String, crate::Value>,
9729        principal: Option<&crate::auth::Principal>,
9730        control: &crate::ExecutionControl,
9731        before_commit: F,
9732    ) -> Result<ProcedureCallResult>
9733    where
9734        F: FnOnce() -> bool,
9735    {
9736        // v1 requires ALL to call procedures on a require_auth database; a
9737        // finer SECURITY DEFINER-style marker is a future extension (spec §9
9738        // decision 1).
9739        self.require_for(principal, &crate::auth::Permission::All)?;
9740        let procedure = self
9741            .procedure(name)
9742            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
9743        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
9744    }
9745
9746    fn execute_procedure_as_controlled<F>(
9747        &self,
9748        procedure: StoredProcedure,
9749        args: HashMap<String, crate::Value>,
9750        principal: Option<&crate::auth::Principal>,
9751        control: &crate::ExecutionControl,
9752        before_commit: F,
9753    ) -> Result<ProcedureCallResult>
9754    where
9755        F: FnOnce() -> bool,
9756    {
9757        let args = bind_procedure_args(&procedure, args)?;
9758        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
9759        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
9760        if has_writes {
9761            let mut tx = self.begin_as(principal.cloned());
9762            let run = (|| {
9763                for (step_index, step) in procedure.body.steps.iter().enumerate() {
9764                    if step_index % 256 == 0 {
9765                        control.checkpoint()?;
9766                    }
9767                    let output = self.execute_procedure_step(
9768                        step,
9769                        &args,
9770                        &outputs,
9771                        Some(&mut tx),
9772                        principal,
9773                        Some(control),
9774                    )?;
9775                    outputs.insert(step.id().to_string(), output);
9776                }
9777                control.checkpoint()?;
9778                eval_return_output(&procedure.body.return_value, &args, &outputs)
9779            })();
9780            match run {
9781                Ok(output) => {
9782                    control.checkpoint()?;
9783                    if !before_commit() {
9784                        tx.rollback();
9785                        return Err(MongrelError::Cancelled);
9786                    }
9787                    let epoch = tx.commit()?.0;
9788                    Ok(ProcedureCallResult {
9789                        epoch: Some(epoch),
9790                        output,
9791                    })
9792                }
9793                Err(e) => {
9794                    tx.rollback();
9795                    Err(e)
9796                }
9797            }
9798        } else {
9799            for (step_index, step) in procedure.body.steps.iter().enumerate() {
9800                if step_index % 256 == 0 {
9801                    control.checkpoint()?;
9802                }
9803                let output = self.execute_procedure_step(
9804                    step,
9805                    &args,
9806                    &outputs,
9807                    None,
9808                    principal,
9809                    Some(control),
9810                )?;
9811                outputs.insert(step.id().to_string(), output);
9812            }
9813            control.checkpoint()?;
9814            Ok(ProcedureCallResult {
9815                epoch: None,
9816                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
9817            })
9818        }
9819    }
9820
9821    fn execute_procedure_step(
9822        &self,
9823        step: &ProcedureStep,
9824        args: &HashMap<String, crate::Value>,
9825        outputs: &HashMap<String, ProcedureCallOutput>,
9826        tx: Option<&mut crate::txn::Transaction<'_>>,
9827        principal: Option<&crate::auth::Principal>,
9828        control: Option<&crate::ExecutionControl>,
9829    ) -> Result<ProcedureCallOutput> {
9830        if let Some(control) = control {
9831            control.checkpoint()?;
9832        }
9833        match step {
9834            ProcedureStep::NativeQuery {
9835                table,
9836                conditions,
9837                projection,
9838                limit,
9839                ..
9840            } => {
9841                let mut q = crate::Query::new();
9842                for condition in conditions {
9843                    q = q.and(eval_condition(condition, args, outputs)?);
9844                }
9845                let fallback_control = crate::ExecutionControl::new(None);
9846                let query_control = control.unwrap_or(&fallback_control);
9847                let mut rows = self.query_for_principal_controlled(
9848                    table,
9849                    &q,
9850                    projection.as_deref(),
9851                    principal,
9852                    false,
9853                    query_control,
9854                )?;
9855                if let Some(limit) = limit {
9856                    rows.truncate(*limit);
9857                }
9858                let mut output = Vec::with_capacity(rows.len());
9859                for (row_index, row) in rows.into_iter().enumerate() {
9860                    if row_index % 256 == 0 {
9861                        if let Some(control) = control {
9862                            control.checkpoint()?;
9863                        }
9864                    }
9865                    output.push(ProcedureCallRow {
9866                        row_id: Some(row.row_id),
9867                        columns: row.columns,
9868                    });
9869                }
9870                Ok(ProcedureCallOutput::Rows(output))
9871            }
9872            ProcedureStep::Put {
9873                table,
9874                cells,
9875                returning,
9876                ..
9877            } => {
9878                let tx = tx.ok_or_else(|| {
9879                    MongrelError::InvalidArgument(
9880                        "write procedure step requires a transaction".into(),
9881                    )
9882                })?;
9883                let cells = eval_cells(cells, args, outputs)?;
9884                if *returning {
9885                    let out = tx.put_returning(table, cells)?;
9886                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
9887                        row_id: None,
9888                        columns: out.row.columns.into_iter().collect(),
9889                    }))
9890                } else {
9891                    tx.put(table, cells)?;
9892                    Ok(ProcedureCallOutput::Null)
9893                }
9894            }
9895            ProcedureStep::Upsert {
9896                table,
9897                cells,
9898                update_cells,
9899                returning,
9900                ..
9901            } => {
9902                let tx = tx.ok_or_else(|| {
9903                    MongrelError::InvalidArgument(
9904                        "write procedure step requires a transaction".into(),
9905                    )
9906                })?;
9907                let cells = eval_cells(cells, args, outputs)?;
9908                let action = match update_cells {
9909                    Some(update_cells) => {
9910                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
9911                    }
9912                    None => crate::UpsertAction::DoNothing,
9913                };
9914                let out = tx.upsert(table, cells, action)?;
9915                if *returning {
9916                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
9917                        row_id: None,
9918                        columns: out.row.columns.into_iter().collect(),
9919                    }))
9920                } else {
9921                    Ok(ProcedureCallOutput::Null)
9922                }
9923            }
9924            ProcedureStep::DeleteByPk { table, pk, .. } => {
9925                let tx = tx.ok_or_else(|| {
9926                    MongrelError::InvalidArgument(
9927                        "write procedure step requires a transaction".into(),
9928                    )
9929                })?;
9930                let pk = eval_value(pk, args, outputs)?;
9931                let handle = self.table(table)?;
9932                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
9933                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
9934                })?;
9935                tx.delete(table, row_id)?;
9936                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
9937            }
9938            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
9939                "DeleteRows procedure step is not supported by the core executor yet".into(),
9940            )),
9941            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
9942                "SqlQuery procedure step must be executed by mongreldb-query".into(),
9943            )),
9944        }
9945    }
9946
9947    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
9948        let cat = self.catalog.read();
9949        for step in &procedure.body.steps {
9950            let Some(table_name) = step.table() else {
9951                continue;
9952            };
9953            let schema = &cat
9954                .live(table_name)
9955                .ok_or_else(|| {
9956                    MongrelError::InvalidArgument(format!(
9957                        "procedure {:?} references unknown table {table_name:?}",
9958                        procedure.name
9959                    ))
9960                })?
9961                .schema;
9962            match step {
9963                ProcedureStep::NativeQuery {
9964                    conditions,
9965                    projection,
9966                    ..
9967                } => {
9968                    for condition in conditions {
9969                        validate_condition_columns(condition, schema)?;
9970                    }
9971                    if let Some(projection) = projection {
9972                        for id in projection {
9973                            validate_column_id(*id, schema)?;
9974                        }
9975                    }
9976                }
9977                ProcedureStep::Put { cells, .. } => {
9978                    for cell in cells {
9979                        validate_column_id(cell.column_id, schema)?;
9980                    }
9981                }
9982                ProcedureStep::Upsert {
9983                    cells,
9984                    update_cells,
9985                    ..
9986                } => {
9987                    for cell in cells {
9988                        validate_column_id(cell.column_id, schema)?;
9989                    }
9990                    if let Some(update_cells) = update_cells {
9991                        for cell in update_cells {
9992                            validate_column_id(cell.column_id, schema)?;
9993                        }
9994                    }
9995                }
9996                ProcedureStep::DeleteByPk { .. } => {
9997                    if schema.primary_key().is_none() {
9998                        return Err(MongrelError::InvalidArgument(format!(
9999                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
10000                            procedure.name
10001                        )));
10002                    }
10003                }
10004                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
10005            }
10006        }
10007        Ok(())
10008    }
10009
10010    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
10011        let cat = self.catalog.read();
10012        let target_schema = match &trigger.target {
10013            TriggerTarget::Table(target_name) => cat
10014                .live(target_name)
10015                .ok_or_else(|| {
10016                    MongrelError::InvalidArgument(format!(
10017                        "trigger {:?} references unknown target table {target_name:?}",
10018                        trigger.name
10019                    ))
10020                })?
10021                .schema
10022                .clone(),
10023            TriggerTarget::View(_) => Schema {
10024                columns: trigger.target_columns.clone(),
10025                ..Schema::default()
10026            },
10027        };
10028        for col in &trigger.update_of {
10029            if target_schema.column(col).is_none() {
10030                return Err(MongrelError::InvalidArgument(format!(
10031                    "trigger {:?} UPDATE OF references unknown column {col:?}",
10032                    trigger.name
10033                )));
10034            }
10035        }
10036        if let Some(expr) = &trigger.when {
10037            validate_trigger_expr(expr, &target_schema, trigger.event)?;
10038        }
10039        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
10040        for step in &trigger.program.steps {
10041            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
10042            {
10043                return Err(MongrelError::InvalidArgument(
10044                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
10045                ));
10046            }
10047            validate_trigger_step(
10048                step,
10049                &cat,
10050                &target_schema,
10051                trigger.event,
10052                &mut select_schemas,
10053            )?;
10054        }
10055        Ok(())
10056    }
10057
10058    /// Begin a new transaction reading at the current visible epoch.
10059    pub fn begin(&self) -> crate::txn::Transaction<'_> {
10060        self.begin_with_isolation(crate::txn::IsolationLevel::default())
10061    }
10062
10063    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
10064        let principal = self.principal.read().clone();
10065        let catalog_bound = principal
10066            .as_ref()
10067            .is_some_and(|principal| principal.user_id != 0);
10068        (principal, catalog_bound)
10069    }
10070
10071    pub fn begin_as(
10072        &self,
10073        principal: Option<crate::auth::Principal>,
10074    ) -> crate::txn::Transaction<'_> {
10075        let catalog_bound = principal
10076            .as_ref()
10077            .is_some_and(|principal| principal.user_id != 0);
10078        let txn_id = self.alloc_txn_id();
10079        let read = Snapshot::at(self.epoch.visible());
10080        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10081            .with_principal(principal, catalog_bound)
10082    }
10083
10084    /// Begin a transaction with a specific isolation level.
10085    pub fn begin_with_isolation(
10086        &self,
10087        level: crate::txn::IsolationLevel,
10088    ) -> crate::txn::Transaction<'_> {
10089        let txn_id = self.alloc_txn_id();
10090        // Every level pins the current visible epoch at begin; ReadCommitted
10091        // re-pins per statement inside the transaction (S1B-002).
10092        let read = Snapshot::at(self.epoch.visible());
10093        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10094        crate::txn::Transaction::new(self, txn_id, read, level)
10095            .with_principal(principal, catalog_bound)
10096    }
10097
10098    /// Begin a transaction whose trigger programs may route external-table DML
10099    /// through an application/query-layer module bridge.
10100    pub fn begin_with_external_trigger_bridge<'a>(
10101        &'a self,
10102        bridge: &'a dyn ExternalTriggerBridge,
10103    ) -> crate::txn::Transaction<'a> {
10104        let txn_id = self.alloc_txn_id();
10105        let read = Snapshot::at(self.epoch.visible());
10106        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10107        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10108            .with_external_trigger_bridge(bridge)
10109            .with_principal(principal, catalog_bound)
10110    }
10111
10112    pub fn begin_with_external_trigger_bridge_as<'a>(
10113        &'a self,
10114        bridge: &'a dyn ExternalTriggerBridge,
10115        principal: Option<crate::auth::Principal>,
10116    ) -> crate::txn::Transaction<'a> {
10117        let catalog_bound = principal
10118            .as_ref()
10119            .is_some_and(|principal| principal.user_id != 0);
10120        let txn_id = self.alloc_txn_id();
10121        let read = Snapshot::at(self.epoch.visible());
10122        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10123            .with_external_trigger_bridge(bridge)
10124            .with_principal(principal, catalog_bound)
10125    }
10126
10127    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
10128    pub fn transaction<T>(
10129        &self,
10130        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10131    ) -> Result<T> {
10132        let mut tx = self.begin();
10133        match f(&mut tx) {
10134            Ok(out) => {
10135                tx.commit()?;
10136                Ok(out)
10137            }
10138            Err(e) => {
10139                tx.rollback();
10140                Err(e)
10141            }
10142        }
10143    }
10144
10145    pub fn transaction_with_row_ids<T>(
10146        &self,
10147        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10148    ) -> Result<(T, Vec<RowId>)> {
10149        let mut tx = self.begin();
10150        match f(&mut tx) {
10151            Ok(output) => {
10152                let (_, row_ids) = tx.commit_with_row_ids()?;
10153                Ok((output, row_ids))
10154            }
10155            Err(error) => {
10156                tx.rollback();
10157                Err(error)
10158            }
10159        }
10160    }
10161
10162    pub fn transaction_for_current_principal<T>(
10163        &self,
10164        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10165    ) -> Result<T> {
10166        if self.principal.read().is_some() {
10167            self.refresh_principal()?;
10168        }
10169        let mut transaction = self.begin_as(self.principal.read().clone());
10170        match f(&mut transaction) {
10171            Ok(output) => {
10172                transaction.commit()?;
10173                Ok(output)
10174            }
10175            Err(error) => {
10176                transaction.rollback();
10177                Err(error)
10178            }
10179        }
10180    }
10181
10182    pub fn transaction_for_current_principal_with_epoch<T>(
10183        &self,
10184        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10185    ) -> Result<(Epoch, T)> {
10186        if self.principal.read().is_some() {
10187            self.refresh_principal()?;
10188        }
10189        let mut transaction = self.begin_as(self.principal.read().clone());
10190        match f(&mut transaction) {
10191            Ok(output) => {
10192                let epoch = transaction.commit()?;
10193                Ok((epoch, output))
10194            }
10195            Err(error) => {
10196                transaction.rollback();
10197                Err(error)
10198            }
10199        }
10200    }
10201
10202    pub fn transaction_with_row_ids_for_current_principal<T>(
10203        &self,
10204        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10205    ) -> Result<(T, Vec<RowId>)> {
10206        if self.principal.read().is_some() {
10207            self.refresh_principal()?;
10208        }
10209        let mut transaction = self.begin_as(self.principal.read().clone());
10210        match f(&mut transaction) {
10211            Ok(output) => {
10212                let (_, row_ids) = transaction.commit_with_row_ids()?;
10213                Ok((output, row_ids))
10214            }
10215            Err(error) => {
10216                transaction.rollback();
10217                Err(error)
10218            }
10219        }
10220    }
10221
10222    /// Run `f` in a transaction with an external-trigger bridge; commit on
10223    /// `Ok`, rollback on `Err`.
10224    pub fn transaction_with_external_trigger_bridge<'a, T>(
10225        &'a self,
10226        bridge: &'a dyn ExternalTriggerBridge,
10227        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10228    ) -> Result<T> {
10229        let mut tx = self.begin_with_external_trigger_bridge(bridge);
10230        match f(&mut tx) {
10231            Ok(out) => {
10232                tx.commit()?;
10233                Ok(out)
10234            }
10235            Err(e) => {
10236                tx.rollback();
10237                Err(e)
10238            }
10239        }
10240    }
10241
10242    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
10243        &'a self,
10244        bridge: &'a dyn ExternalTriggerBridge,
10245        principal: Option<crate::auth::Principal>,
10246        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10247    ) -> Result<T> {
10248        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
10249        match f(&mut tx) {
10250            Ok(output) => {
10251                tx.commit()?;
10252                Ok(output)
10253            }
10254            Err(error) => {
10255                tx.rollback();
10256                Err(error)
10257            }
10258        }
10259    }
10260
10261    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
10262    /// `Transaction::new` so registration happens **before** any read.
10263    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
10264        self.active_txns.register(epoch)
10265    }
10266
10267    fn fill_auto_increment_for_staging(
10268        &self,
10269        txn_id: u64,
10270        staging: &mut [(u64, crate::txn::Staged)],
10271        control: Option<&crate::ExecutionControl>,
10272    ) -> Result<()> {
10273        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
10274        for (index, (table_id, staged)) in staging.iter().enumerate() {
10275            commit_prepare_checkpoint(control, index)?;
10276            if matches!(staged, crate::txn::Staged::Put(_)) {
10277                puts_by_table.entry(*table_id).or_default().push(index);
10278            }
10279        }
10280
10281        // S1B-003: sequence allocation serializes on one Exclusive barrier per
10282        // staged table whose puts actually ALLOCATE an auto-increment value
10283        // (absent/Null column — explicit values only advance the counter and
10284        // must not serialize), held until the transaction ends so allocated
10285        // values map monotonically onto commit order. The barrier is acquired
10286        // before the table lock (never the reverse).
10287        {
10288            let mut barrier_tables: Vec<u64> = puts_by_table
10289                .iter()
10290                .filter(|(table_id, indexes)| {
10291                    indexes.iter().any(|index| {
10292                        matches!(
10293                            &staging[*index].1,
10294                            crate::txn::Staged::Put(cells)
10295                                if self.table_auto_inc_would_allocate(**table_id, cells)
10296                        )
10297                    })
10298                })
10299                .map(|(table_id, _)| *table_id)
10300                .collect();
10301            barrier_tables.sort_unstable();
10302            for table_id in barrier_tables {
10303                self.acquire_txn_lock(
10304                    txn_id,
10305                    crate::locks::LockKey::sequence_barrier(&format!("auto_inc:{table_id}")),
10306                    crate::locks::LockMode::Exclusive,
10307                    control,
10308                )?;
10309            }
10310        }
10311
10312        let tables = self.tables.read();
10313        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
10314            commit_prepare_checkpoint(control, table_index)?;
10315            if let Some(handle) = tables.get(&table_id) {
10316                #[cfg(test)]
10317                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10318                let mut t = handle.lock();
10319                for (fill_index, index) in indexes.into_iter().enumerate() {
10320                    commit_prepare_checkpoint(control, fill_index)?;
10321                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
10322                        t.fill_auto_inc(cells)?;
10323                    }
10324                }
10325            }
10326        }
10327        Ok(())
10328    }
10329
10330    fn expand_table_triggers(
10331        &self,
10332        txn_id: u64,
10333        staging: &mut Vec<(u64, crate::txn::Staged)>,
10334        read_epoch: Epoch,
10335        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
10336        external_states: &mut Vec<(String, Vec<u8>)>,
10337        control: Option<&crate::ExecutionControl>,
10338    ) -> Result<()> {
10339        commit_prepare_checkpoint(control, 0)?;
10340        let mut external_writes = Vec::new();
10341        let config = self.trigger_config();
10342        if config.recursive_triggers {
10343            let chunk = std::mem::take(staging);
10344            let stacks = vec![Vec::new(); chunk.len()];
10345            *staging = self.expand_trigger_chunk(
10346                txn_id,
10347                chunk,
10348                stacks,
10349                read_epoch,
10350                0,
10351                config.max_depth,
10352                &mut external_writes,
10353                &config,
10354                control,
10355            )?;
10356            self.apply_external_trigger_writes(
10357                external_writes,
10358                external_trigger_bridge,
10359                external_states,
10360                staging,
10361                control,
10362            )?;
10363            return Ok(());
10364        }
10365
10366        let mut expansion =
10367            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
10368        if !expansion.before.is_empty() {
10369            let mut final_staging = expansion.before;
10370            final_staging.extend(filter_ignored_staging(
10371                std::mem::take(staging),
10372                &expansion.ignored_indices,
10373            ));
10374            *staging = final_staging;
10375        } else if !expansion.ignored_indices.is_empty() {
10376            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
10377        }
10378        staging.append(&mut expansion.after);
10379        external_writes.append(&mut expansion.before_external);
10380        external_writes.append(&mut expansion.after_external);
10381        self.apply_external_trigger_writes(
10382            external_writes,
10383            external_trigger_bridge,
10384            external_states,
10385            staging,
10386            control,
10387        )?;
10388        Ok(())
10389    }
10390
10391    #[allow(clippy::too_many_arguments)]
10392    fn expand_trigger_chunk(
10393        &self,
10394        txn_id: u64,
10395        mut chunk: Vec<(u64, crate::txn::Staged)>,
10396        stacks: Vec<Vec<String>>,
10397        read_epoch: Epoch,
10398        depth: u32,
10399        max_depth: u32,
10400        external_writes: &mut Vec<ExternalTriggerWrite>,
10401        config: &TriggerConfig,
10402        control: Option<&crate::ExecutionControl>,
10403    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
10404        if chunk.is_empty() {
10405            return Ok(Vec::new());
10406        }
10407        commit_prepare_checkpoint(control, 0)?;
10408        self.fill_auto_increment_for_staging(txn_id, &mut chunk, control)?;
10409        let expansion = self.expand_table_triggers_once(
10410            &mut chunk,
10411            read_epoch,
10412            Some(&stacks),
10413            config,
10414            control,
10415        )?;
10416        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
10417            let stack = expansion
10418                .before_stacks
10419                .first()
10420                .or_else(|| expansion.after_stacks.first())
10421                .cloned()
10422                .unwrap_or_default();
10423            return Err(MongrelError::TriggerValidation(format!(
10424                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
10425                Self::format_trigger_stack(&stack)
10426            )));
10427        }
10428
10429        let mut out = Vec::new();
10430        external_writes.extend(expansion.before_external);
10431        out.extend(self.expand_trigger_chunk(
10432            txn_id,
10433            expansion.before,
10434            expansion.before_stacks,
10435            read_epoch,
10436            depth + 1,
10437            max_depth,
10438            external_writes,
10439            config,
10440            control,
10441        )?);
10442        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
10443        external_writes.extend(expansion.after_external);
10444        out.extend(self.expand_trigger_chunk(
10445            txn_id,
10446            expansion.after,
10447            expansion.after_stacks,
10448            read_epoch,
10449            depth + 1,
10450            max_depth,
10451            external_writes,
10452            config,
10453            control,
10454        )?);
10455        Ok(out)
10456    }
10457
10458    fn apply_external_trigger_writes(
10459        &self,
10460        writes: Vec<ExternalTriggerWrite>,
10461        bridge: Option<&dyn ExternalTriggerBridge>,
10462        external_states: &mut Vec<(String, Vec<u8>)>,
10463        staging: &mut Vec<(u64, crate::txn::Staged)>,
10464        control: Option<&crate::ExecutionControl>,
10465    ) -> Result<()> {
10466        if writes.is_empty() {
10467            return Ok(());
10468        }
10469        let bridge = bridge.ok_or_else(|| {
10470            MongrelError::TriggerValidation(
10471                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
10472            )
10473        })?;
10474        for (write_index, write) in writes.into_iter().enumerate() {
10475            commit_prepare_checkpoint(control, write_index)?;
10476            let table = write.table().to_string();
10477            let entry = self.external_table(&table).ok_or_else(|| {
10478                MongrelError::NotFound(format!("external table {table:?} not found"))
10479            })?;
10480            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
10481            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
10482            external_states.push((table, result.state));
10483            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
10484                commit_prepare_checkpoint(control, base_index)?;
10485                match base_write {
10486                    ExternalTriggerBaseWrite::Put { table, cells } => {
10487                        let table_id = self.table_id(&table)?;
10488                        staging.push((table_id, crate::txn::Staged::Put(cells)));
10489                    }
10490                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
10491                        let table_id = self.table_id(&table)?;
10492                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
10493                    }
10494                }
10495            }
10496        }
10497        dedup_external_states_in_place(external_states);
10498        Ok(())
10499    }
10500
10501    fn expand_table_triggers_once(
10502        &self,
10503        staging: &mut Vec<(u64, crate::txn::Staged)>,
10504        read_epoch: Epoch,
10505        trigger_stacks: Option<&[Vec<String>]>,
10506        config: &TriggerConfig,
10507        control: Option<&crate::ExecutionControl>,
10508    ) -> Result<TriggerExpansion> {
10509        commit_prepare_checkpoint(control, 0)?;
10510        let triggers: Vec<StoredTrigger> = self
10511            .catalog
10512            .read()
10513            .triggers
10514            .iter()
10515            .filter(|entry| {
10516                entry.trigger.enabled
10517                    && matches!(
10518                        entry.trigger.timing,
10519                        TriggerTiming::Before | TriggerTiming::After
10520                    )
10521                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
10522            })
10523            .map(|entry| entry.trigger.clone())
10524            .collect();
10525        if triggers.is_empty() || staging.is_empty() {
10526            return Ok(TriggerExpansion::default());
10527        }
10528
10529        let before_triggers = triggers
10530            .iter()
10531            .filter(|trigger| trigger.timing == TriggerTiming::Before)
10532            .cloned()
10533            .collect::<Vec<_>>();
10534        let after_triggers = triggers
10535            .iter()
10536            .filter(|trigger| trigger.timing == TriggerTiming::After)
10537            .cloned()
10538            .collect::<Vec<_>>();
10539
10540        let mut before_added = Vec::new();
10541        let mut before_stacks = Vec::new();
10542        let mut before_external = Vec::new();
10543        let mut ignored_indices = std::collections::BTreeSet::new();
10544        if !before_triggers.is_empty() {
10545            let before_events =
10546                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
10547            let mut out = TriggerProgramOutput {
10548                added: &mut before_added,
10549                added_stacks: &mut before_stacks,
10550                added_external: &mut before_external,
10551                ignored_indices: &mut ignored_indices,
10552            };
10553            self.execute_triggers_for_events(
10554                &before_triggers,
10555                &before_events,
10556                Some(staging),
10557                &mut out,
10558                config,
10559                read_epoch,
10560                control,
10561            )?;
10562        }
10563
10564        let after_events = if after_triggers.is_empty() {
10565            Vec::new()
10566        } else {
10567            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
10568                .into_iter()
10569                .filter(|event| {
10570                    !event
10571                        .op_indices
10572                        .iter()
10573                        .any(|idx| ignored_indices.contains(idx))
10574                })
10575                .collect()
10576        };
10577
10578        let mut after_added = Vec::new();
10579        let mut after_stacks = Vec::new();
10580        let mut after_external = Vec::new();
10581        let mut out = TriggerProgramOutput {
10582            added: &mut after_added,
10583            added_stacks: &mut after_stacks,
10584            added_external: &mut after_external,
10585            ignored_indices: &mut ignored_indices,
10586        };
10587        self.execute_triggers_for_events(
10588            &after_triggers,
10589            &after_events,
10590            None,
10591            &mut out,
10592            config,
10593            read_epoch,
10594            control,
10595        )?;
10596        Ok(TriggerExpansion {
10597            before: before_added,
10598            before_stacks,
10599            before_external,
10600            after: after_added,
10601            after_stacks,
10602            after_external,
10603            ignored_indices,
10604        })
10605    }
10606
10607    #[allow(clippy::too_many_arguments)]
10608    fn execute_triggers_for_events(
10609        &self,
10610        triggers: &[StoredTrigger],
10611        events: &[WriteEvent],
10612        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
10613        out: &mut TriggerProgramOutput<'_>,
10614        config: &TriggerConfig,
10615        read_epoch: Epoch,
10616        control: Option<&crate::ExecutionControl>,
10617    ) -> Result<()> {
10618        let mut checkpoint_index = 0_usize;
10619        for event in events {
10620            for trigger in triggers {
10621                commit_prepare_checkpoint(control, checkpoint_index)?;
10622                checkpoint_index += 1;
10623                if event
10624                    .op_indices
10625                    .iter()
10626                    .any(|idx| out.ignored_indices.contains(idx))
10627                {
10628                    break;
10629                }
10630                let matches = {
10631                    let cat = self.catalog.read();
10632                    trigger_matches_event(trigger, event, &cat)?
10633                };
10634                if !matches {
10635                    continue;
10636                }
10637                if let Some(when) = &trigger.when {
10638                    if !eval_trigger_expr(when, event)? {
10639                        continue;
10640                    }
10641                }
10642                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
10643                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
10644                    return Err(MongrelError::TriggerValidation(format!(
10645                        "trigger recursion cycle detected; trigger stack: {}",
10646                        Self::format_trigger_stack(&trigger_stack)
10647                    )));
10648                }
10649                let outcome = match staging.as_mut() {
10650                    Some(staging) => self.execute_trigger_program(
10651                        trigger,
10652                        event,
10653                        Some(&mut **staging),
10654                        out,
10655                        &trigger_stack,
10656                        config,
10657                        read_epoch,
10658                        control,
10659                    )?,
10660                    None => self.execute_trigger_program(
10661                        trigger,
10662                        event,
10663                        None,
10664                        out,
10665                        &trigger_stack,
10666                        config,
10667                        read_epoch,
10668                        control,
10669                    )?,
10670                };
10671                if outcome == TriggerProgramOutcome::Ignore {
10672                    out.ignored_indices.extend(event.op_indices.iter().copied());
10673                    break;
10674                }
10675            }
10676        }
10677        Ok(())
10678    }
10679
10680    fn trigger_events_for_staging(
10681        &self,
10682        staging: &[(u64, crate::txn::Staged)],
10683        read_epoch: Epoch,
10684        trigger_stacks: Option<&[Vec<String>]>,
10685        control: Option<&crate::ExecutionControl>,
10686    ) -> Result<Vec<WriteEvent>> {
10687        use crate::txn::Staged;
10688        use std::collections::{HashMap, VecDeque};
10689
10690        let snapshot = Snapshot::at(read_epoch);
10691        let cat = self.catalog.read();
10692        let mut table_names = HashMap::new();
10693        let mut table_schemas = HashMap::new();
10694        for entry in cat
10695            .tables
10696            .iter()
10697            .filter(|entry| matches!(entry.state, TableState::Live))
10698        {
10699            table_names.insert(entry.table_id, entry.name.clone());
10700            table_schemas.insert(entry.table_id, entry.schema.clone());
10701        }
10702        drop(cat);
10703
10704        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
10705        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
10706        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
10707
10708        for (idx, (table_id, staged)) in staging.iter().enumerate() {
10709            commit_prepare_checkpoint(control, idx)?;
10710            let Some(schema) = table_schemas.get(table_id) else {
10711                continue;
10712            };
10713            let Some(pk) = schema.primary_key() else {
10714                continue;
10715            };
10716            match staged {
10717                Staged::Delete(row_id) => {
10718                    let handle = self.table_by_id(*table_id)?;
10719                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
10720                        continue;
10721                    };
10722                    let Some(pk_value) = row.columns.get(&pk.id) else {
10723                        continue;
10724                    };
10725                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
10726                    delete_by_key
10727                        .entry((*table_id, pk_value.encode_key()))
10728                        .or_default()
10729                        .push_back(idx);
10730                }
10731                Staged::Put(cells) => {
10732                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
10733                        put_by_key
10734                            .entry((*table_id, value.encode_key()))
10735                            .or_default()
10736                            .push_back(idx);
10737                    }
10738                }
10739                Staged::Update { row_id, .. } => {
10740                    let handle = self.table_by_id(*table_id)?;
10741                    let row = handle.lock().get(*row_id, snapshot);
10742                    if let Some(row) = row {
10743                        old_rows.insert(idx, TriggerRowImage::from_row(row));
10744                    }
10745                }
10746                Staged::Truncate => {}
10747            }
10748        }
10749
10750        let mut paired_delete = std::collections::HashSet::new();
10751        let mut paired_put = std::collections::HashSet::new();
10752        let mut events = Vec::new();
10753
10754        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
10755            commit_prepare_checkpoint(control, pair_index)?;
10756            let Some(puts) = put_by_key.get_mut(key) else {
10757                continue;
10758            };
10759            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
10760                paired_delete.insert(delete_idx);
10761                paired_put.insert(put_idx);
10762                let (table_id, _) = &staging[put_idx];
10763                let Some(table_name) = table_names.get(table_id).cloned() else {
10764                    continue;
10765                };
10766                let old = old_rows.get(&delete_idx).cloned();
10767                let new = match &staging[put_idx].1 {
10768                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
10769                    _ => None,
10770                };
10771                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
10772                events.push(WriteEvent {
10773                    table: table_name,
10774                    kind: TriggerEvent::Update,
10775                    old,
10776                    new,
10777                    changed_columns,
10778                    op_indices: vec![delete_idx, put_idx],
10779                    put_idx: Some(put_idx),
10780                    trigger_stack: Self::trigger_stack_for_indices(
10781                        trigger_stacks,
10782                        &[delete_idx, put_idx],
10783                    ),
10784                });
10785            }
10786        }
10787
10788        for (idx, (table_id, staged)) in staging.iter().enumerate() {
10789            commit_prepare_checkpoint(control, idx)?;
10790            let Some(table_name) = table_names.get(table_id).cloned() else {
10791                continue;
10792            };
10793            match staged {
10794                Staged::Put(cells) if !paired_put.contains(&idx) => {
10795                    let new = Some(TriggerRowImage::from_cells(cells));
10796                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
10797                    events.push(WriteEvent {
10798                        table: table_name,
10799                        kind: TriggerEvent::Insert,
10800                        old: None,
10801                        new,
10802                        changed_columns,
10803                        op_indices: vec![idx],
10804                        put_idx: Some(idx),
10805                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
10806                    });
10807                }
10808                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
10809                    let old = match old_rows.get(&idx).cloned() {
10810                        Some(old) => Some(old),
10811                        None => {
10812                            let handle = self.table_by_id(*table_id)?;
10813                            let row = handle.lock().get(*row_id, snapshot);
10814                            row.map(TriggerRowImage::from_row)
10815                        }
10816                    };
10817                    let Some(old) = old else {
10818                        continue;
10819                    };
10820                    let changed_columns = old.columns.keys().copied().collect();
10821                    events.push(WriteEvent {
10822                        table: table_name,
10823                        kind: TriggerEvent::Delete,
10824                        old: Some(old),
10825                        new: None,
10826                        changed_columns,
10827                        op_indices: vec![idx],
10828                        put_idx: None,
10829                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
10830                    });
10831                }
10832                Staged::Update { new_row: cells, .. } => {
10833                    let old = old_rows.get(&idx).cloned();
10834                    let new = Some(TriggerRowImage::from_cells(cells));
10835                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
10836                    events.push(WriteEvent {
10837                        table: table_name,
10838                        kind: TriggerEvent::Update,
10839                        old,
10840                        new,
10841                        changed_columns,
10842                        op_indices: vec![idx],
10843                        put_idx: Some(idx),
10844                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
10845                    });
10846                }
10847                Staged::Truncate => {}
10848                _ => {}
10849            }
10850        }
10851
10852        Ok(events)
10853    }
10854
10855    #[allow(clippy::too_many_arguments)]
10856    fn execute_trigger_program(
10857        &self,
10858        trigger: &StoredTrigger,
10859        event: &WriteEvent,
10860        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
10861        out: &mut TriggerProgramOutput<'_>,
10862        trigger_stack: &[String],
10863        config: &TriggerConfig,
10864        read_epoch: Epoch,
10865        control: Option<&crate::ExecutionControl>,
10866    ) -> Result<TriggerProgramOutcome> {
10867        let mut event = event.clone();
10868        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
10869        self.execute_trigger_steps(
10870            trigger,
10871            &trigger.program.steps,
10872            &mut event,
10873            staging,
10874            out,
10875            trigger_stack,
10876            config,
10877            &mut select_results,
10878            0,
10879            None,
10880            read_epoch,
10881            control,
10882        )
10883    }
10884
10885    #[allow(clippy::too_many_arguments)]
10886    fn execute_trigger_steps(
10887        &self,
10888        trigger: &StoredTrigger,
10889        steps: &[TriggerStep],
10890        event: &mut WriteEvent,
10891        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
10892        out: &mut TriggerProgramOutput<'_>,
10893        trigger_stack: &[String],
10894        config: &TriggerConfig,
10895        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
10896        depth: u32,
10897        selected: Option<&TriggerRowImage>,
10898        read_epoch: Epoch,
10899        control: Option<&crate::ExecutionControl>,
10900    ) -> Result<TriggerProgramOutcome> {
10901        let _ = depth;
10902        for (step_index, step) in steps.iter().enumerate() {
10903            commit_prepare_checkpoint(control, step_index)?;
10904            match step {
10905                TriggerStep::SetNew { cells } => {
10906                    if trigger.timing != TriggerTiming::Before {
10907                        return Err(MongrelError::InvalidArgument(
10908                            "SetNew trigger step is only valid in BEFORE triggers".into(),
10909                        ));
10910                    }
10911                    let put_idx = event.put_idx.ok_or_else(|| {
10912                        MongrelError::InvalidArgument(
10913                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
10914                        )
10915                    })?;
10916                    let staging = staging.as_deref_mut().ok_or_else(|| {
10917                        MongrelError::InvalidArgument(
10918                            "SetNew trigger step requires mutable trigger staging".into(),
10919                        )
10920                    })?;
10921                    let mut update_changed_columns = None;
10922                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
10923                        Some(crate::txn::Staged::Put(cells)) => cells,
10924                        Some(crate::txn::Staged::Update {
10925                            new_row,
10926                            changed_columns,
10927                            ..
10928                        }) => {
10929                            update_changed_columns = Some(changed_columns);
10930                            new_row
10931                        }
10932                        _ => {
10933                            return Err(MongrelError::InvalidArgument(
10934                                "SetNew trigger step target row is not mutable".into(),
10935                            ))
10936                        }
10937                    };
10938                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
10939                        row_cells.retain(|(id, _)| *id != column_id);
10940                        row_cells.push((column_id, value.clone()));
10941                        if let Some(changed_columns) = &mut update_changed_columns {
10942                            changed_columns.push(column_id);
10943                        }
10944                        if let Some(new) = &mut event.new {
10945                            new.columns.insert(column_id, value);
10946                        }
10947                    }
10948                    row_cells.sort_by_key(|(id, _)| *id);
10949                    if let Some(changed_columns) = update_changed_columns {
10950                        changed_columns.sort_unstable();
10951                        changed_columns.dedup();
10952                    }
10953                }
10954                TriggerStep::Insert { table, cells } => {
10955                    let cells = eval_trigger_cells(cells, event, selected)?;
10956                    if let Ok(table_id) = self.table_id(table) {
10957                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
10958                        out.added_stacks.push(trigger_stack.to_vec());
10959                    } else if self.external_table(table).is_some() {
10960                        out.added_external.push(ExternalTriggerWrite::Insert {
10961                            table: table.clone(),
10962                            cells,
10963                        });
10964                    } else {
10965                        return Err(MongrelError::NotFound(format!(
10966                            "trigger {:?} insert target {table:?} not found",
10967                            trigger.name
10968                        )));
10969                    }
10970                }
10971                TriggerStep::UpdateByPk { table, pk, cells } => {
10972                    let pk = eval_trigger_value(pk, event, selected)?;
10973                    let cells = eval_trigger_cells(cells, event, selected)?;
10974                    if self.external_table(table).is_some() {
10975                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
10976                            table: table.clone(),
10977                            pk,
10978                            cells,
10979                        });
10980                    } else {
10981                        let row_id = self
10982                            .table(table)?
10983                            .lock()
10984                            .lookup_pk(&pk.encode_key())
10985                            .ok_or_else(|| {
10986                                MongrelError::NotFound(format!(
10987                                    "trigger {:?} update target not found",
10988                                    trigger.name
10989                                ))
10990                            })?;
10991                        let handle = self.table(table)?;
10992                        let snapshot = Snapshot::at(self.epoch.visible());
10993                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
10994                            MongrelError::NotFound(format!(
10995                                "trigger {:?} update target not visible",
10996                                trigger.name
10997                            ))
10998                        })?;
10999                        let mut changed_columns = cells
11000                            .iter()
11001                            .map(|(column_id, _)| *column_id)
11002                            .collect::<Vec<_>>();
11003                        changed_columns.sort_unstable();
11004                        changed_columns.dedup();
11005                        let mut merged = old.columns;
11006                        for (column_id, value) in cells {
11007                            merged.insert(column_id, value);
11008                        }
11009                        out.added.push((
11010                            self.table_id(table)?,
11011                            crate::txn::Staged::Update {
11012                                row_id,
11013                                new_row: merged.into_iter().collect(),
11014                                changed_columns,
11015                            },
11016                        ));
11017                        out.added_stacks.push(trigger_stack.to_vec());
11018                    }
11019                }
11020                TriggerStep::DeleteByPk { table, pk } => {
11021                    let pk = eval_trigger_value(pk, event, selected)?;
11022                    if self.external_table(table).is_some() {
11023                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
11024                            table: table.clone(),
11025                            pk,
11026                        });
11027                    } else {
11028                        let row_id = self
11029                            .table(table)?
11030                            .lock()
11031                            .lookup_pk(&pk.encode_key())
11032                            .ok_or_else(|| {
11033                                MongrelError::NotFound(format!(
11034                                    "trigger {:?} delete target not found",
11035                                    trigger.name
11036                                ))
11037                            })?;
11038                        out.added
11039                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
11040                        out.added_stacks.push(trigger_stack.to_vec());
11041                    }
11042                }
11043                TriggerStep::Select {
11044                    id,
11045                    table,
11046                    conditions,
11047                } => {
11048                    let schema = self.table(table)?.lock().schema().clone();
11049                    let snapshot = Snapshot::at(read_epoch);
11050                    let handle = self.table(table)?;
11051                    let rows = match control {
11052                        Some(control) => {
11053                            handle.lock().visible_rows_controlled(snapshot, control)?
11054                        }
11055                        None => handle.lock().visible_rows(snapshot)?,
11056                    };
11057                    let mut matched = Vec::new();
11058                    for (row_index, row) in rows.into_iter().enumerate() {
11059                        commit_prepare_checkpoint(control, row_index)?;
11060                        let image = TriggerRowImage::from_row(row);
11061                        let passes = conditions
11062                            .iter()
11063                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11064                            .collect::<Result<Vec<_>>>()?
11065                            .into_iter()
11066                            .all(|b| b);
11067                        if passes {
11068                            matched.push(image);
11069                        }
11070                    }
11071                    if let Some(pk) = schema.primary_key() {
11072                        matched.sort_by(|a, b| {
11073                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
11074                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
11075                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
11076                        });
11077                    }
11078                    select_results.insert(id.clone(), matched);
11079                }
11080                TriggerStep::Foreach { id, steps } => {
11081                    let rows = select_results.get(id).ok_or_else(|| {
11082                        MongrelError::InvalidArgument(format!(
11083                            "trigger {:?} foreach references unknown select id {id:?}",
11084                            trigger.name
11085                        ))
11086                    })?;
11087                    if rows.len() > config.max_loop_iterations as usize {
11088                        return Err(MongrelError::InvalidArgument(format!(
11089                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
11090                            trigger.name, config.max_loop_iterations
11091                        )));
11092                    }
11093                    for (row_index, row) in rows.clone().into_iter().enumerate() {
11094                        commit_prepare_checkpoint(control, row_index)?;
11095                        let result = self.execute_trigger_steps(
11096                            trigger,
11097                            steps,
11098                            event,
11099                            staging.as_deref_mut(),
11100                            out,
11101                            trigger_stack,
11102                            config,
11103                            select_results,
11104                            depth + 1,
11105                            Some(&row),
11106                            read_epoch,
11107                            control,
11108                        )?;
11109                        if result == TriggerProgramOutcome::Ignore {
11110                            return Ok(TriggerProgramOutcome::Ignore);
11111                        }
11112                    }
11113                }
11114                TriggerStep::DeleteWhere { table, conditions } => {
11115                    let schema = self.table(table)?.lock().schema().clone();
11116                    let snapshot = Snapshot::at(read_epoch);
11117                    let handle = self.table(table)?;
11118                    let rows = match control {
11119                        Some(control) => {
11120                            handle.lock().visible_rows_controlled(snapshot, control)?
11121                        }
11122                        None => handle.lock().visible_rows(snapshot)?,
11123                    };
11124                    let table_id = self.table_id(table)?;
11125                    let mut to_delete = Vec::new();
11126                    for (row_index, row) in rows.into_iter().enumerate() {
11127                        commit_prepare_checkpoint(control, row_index)?;
11128                        let image = TriggerRowImage::from_row(row.clone());
11129                        let passes = conditions
11130                            .iter()
11131                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11132                            .collect::<Result<Vec<_>>>()?
11133                            .into_iter()
11134                            .all(|b| b);
11135                        if passes {
11136                            to_delete.push((table_id, row.row_id));
11137                        }
11138                    }
11139                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
11140                        commit_prepare_checkpoint(control, row_index)?;
11141                        out.added
11142                            .push((table_id, crate::txn::Staged::Delete(row_id)));
11143                        out.added_stacks.push(trigger_stack.to_vec());
11144                    }
11145                }
11146                TriggerStep::UpdateWhere {
11147                    table,
11148                    conditions,
11149                    cells,
11150                } => {
11151                    let schema = self.table(table)?.lock().schema().clone();
11152                    let snapshot = Snapshot::at(read_epoch);
11153                    let handle = self.table(table)?;
11154                    let rows = match control {
11155                        Some(control) => {
11156                            handle.lock().visible_rows_controlled(snapshot, control)?
11157                        }
11158                        None => handle.lock().visible_rows(snapshot)?,
11159                    };
11160                    let table_id = self.table_id(table)?;
11161                    let mut changed_columns =
11162                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
11163                    changed_columns.sort_unstable();
11164                    changed_columns.dedup();
11165                    let mut to_update = Vec::new();
11166                    for (row_index, row) in rows.into_iter().enumerate() {
11167                        commit_prepare_checkpoint(control, row_index)?;
11168                        let image = TriggerRowImage::from_row(row.clone());
11169                        let passes = conditions
11170                            .iter()
11171                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11172                            .collect::<Result<Vec<_>>>()?
11173                            .into_iter()
11174                            .all(|b| b);
11175                        if passes {
11176                            let new_cells = cells
11177                                .iter()
11178                                .map(|cell| {
11179                                    Ok((
11180                                        cell.column_id,
11181                                        eval_trigger_value(&cell.value, event, Some(&image))?,
11182                                    ))
11183                                })
11184                                .collect::<Result<Vec<_>>>()?;
11185                            let mut merged = row.columns.clone();
11186                            for (column_id, value) in new_cells {
11187                                merged.insert(column_id, value);
11188                            }
11189                            to_update.push((table_id, row.row_id, merged));
11190                        }
11191                    }
11192                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
11193                    {
11194                        commit_prepare_checkpoint(control, row_index)?;
11195                        out.added.push((
11196                            table_id,
11197                            crate::txn::Staged::Update {
11198                                row_id,
11199                                new_row: merged.into_iter().collect(),
11200                                changed_columns: changed_columns.clone(),
11201                            },
11202                        ));
11203                        out.added_stacks.push(trigger_stack.to_vec());
11204                    }
11205                }
11206                TriggerStep::Raise { action, message } => match action {
11207                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
11208                    TriggerRaiseAction::Abort
11209                    | TriggerRaiseAction::Fail
11210                    | TriggerRaiseAction::Rollback => {
11211                        let message = eval_trigger_value(message, event, selected)?;
11212                        return Err(MongrelError::TriggerValidation(format!(
11213                            "trigger {:?} raised: {}; trigger stack: {}",
11214                            trigger.name,
11215                            trigger_message(message),
11216                            Self::format_trigger_stack(trigger_stack)
11217                        )));
11218                    }
11219                },
11220            }
11221        }
11222        Ok(TriggerProgramOutcome::Continue)
11223    }
11224
11225    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
11226        let Some(stacks) = stacks else {
11227            return Vec::new();
11228        };
11229        let mut out = Vec::new();
11230        for idx in indices {
11231            let Some(stack) = stacks.get(*idx) else {
11232                continue;
11233            };
11234            for name in stack {
11235                if !out.iter().any(|existing| existing == name) {
11236                    out.push(name.clone());
11237                }
11238            }
11239        }
11240        out
11241    }
11242
11243    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
11244        let mut out = stack.to_vec();
11245        out.push(trigger_name.to_string());
11246        out
11247    }
11248
11249    fn format_trigger_stack(stack: &[String]) -> String {
11250        if stack.is_empty() {
11251            "<root>".into()
11252        } else {
11253            stack.join(" -> ")
11254        }
11255    }
11256
11257    /// Authoritatively validate every declared constraint on the staged write
11258    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
11259    /// SET NULL actions into explicit child ops. Called from
11260    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
11261    /// violation as an `Err`, aborting the commit atomically. This is the
11262    /// server-side authority point: concurrent remote writers that each pass
11263    /// their own client-side checks still cannot both commit a violating batch.
11264    ///
11265    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
11266    /// intra-transaction dedup; concurrent-txn races are additionally caught by
11267    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
11268    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
11269    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
11270    /// RESTRICT-only (cascade-truncate is unsupported).
11271    /// S1B-003: acquire Exclusive key claims for every primary key and declared
11272    /// UNIQUE key a transaction's staged puts/updates insert, in ascending key
11273    /// order (ordered acquisition cannot cycle on its own). A concurrent
11274    /// transaction claiming the same key blocks until this one ends, turning
11275    /// the optimistic write-write conflict into a serialization point. Claims
11276    /// release with the transaction's [`TxnLockGuard`].
11277    fn acquire_unique_key_claims(
11278        &self,
11279        txn_id: u64,
11280        staging: &[(u64, crate::txn::Staged)],
11281        control: Option<&crate::ExecutionControl>,
11282    ) -> Result<()> {
11283        let catalog = self.catalog.read();
11284        let has_uniques = staging.iter().any(|(table_id, staged)| {
11285            matches!(
11286                staged,
11287                crate::txn::Staged::Put(_) | crate::txn::Staged::Update { .. }
11288            ) && catalog.tables.iter().any(|entry| {
11289                entry.table_id == *table_id
11290                    && (entry.schema.primary_key().is_some()
11291                        || !entry.schema.constraints.uniques.is_empty())
11292            })
11293        });
11294        if !has_uniques {
11295            return Ok(());
11296        }
11297        let mut claims: Vec<(u64, Vec<u8>)> = Vec::new();
11298        for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11299            commit_prepare_checkpoint(control, staged_index)?;
11300            let cells = match staged {
11301                crate::txn::Staged::Put(cells) => cells,
11302                crate::txn::Staged::Update { new_row, .. } => new_row,
11303                _ => continue,
11304            };
11305            let Some(entry) = catalog
11306                .tables
11307                .iter()
11308                .find(|entry| entry.table_id == *table_id)
11309            else {
11310                continue;
11311            };
11312            for column in &entry.schema.columns {
11313                if !column
11314                    .flags
11315                    .contains(crate::schema::ColumnFlags::PRIMARY_KEY)
11316                {
11317                    continue;
11318                }
11319                if let Some((_, value)) = cells.iter().find(|(id, _)| *id == column.id) {
11320                    let mut key = b"pk:".to_vec();
11321                    key.extend_from_slice(&value.encode_key());
11322                    claims.push((*table_id, key));
11323                }
11324            }
11325            // Declared non-PK unique constraints claim their own namespace
11326            // (folding the constraint id into the key, per LockKey::Key's
11327            // multi-key-space rule). NULL components skip the constraint —
11328            // and the claim — per SQL semantics.
11329            let cells_map: HashMap<u16, Value> = cells.iter().cloned().collect();
11330            for uc in &entry.schema.constraints.uniques {
11331                if let Some(composite) =
11332                    crate::constraint::encode_composite_key(&uc.columns, &cells_map)
11333                {
11334                    let mut key = format!("uq{}:", uc.id).into_bytes();
11335                    key.extend_from_slice(&composite);
11336                    claims.push((*table_id, key));
11337                }
11338            }
11339        }
11340        claims.sort();
11341        claims.dedup();
11342        for (table_id, key) in claims {
11343            self.acquire_txn_lock(
11344                txn_id,
11345                crate::locks::LockKey::key(table_id, key),
11346                crate::locks::LockMode::Exclusive,
11347                control,
11348            )?;
11349        }
11350        Ok(())
11351    }
11352
11353    /// S1B-003: one FK parent-protection acquisition. `Err` propagates the
11354    /// deadlock/deadline/cancellation outcome; the test seam fires after each
11355    /// successful acquisition.
11356    fn acquire_fk_lock(
11357        &self,
11358        txn_id: u64,
11359        table_id: u64,
11360        key: &[u8],
11361        mode: crate::locks::LockMode,
11362        control: Option<&crate::ExecutionControl>,
11363    ) -> Result<()> {
11364        let mut namespaced = b"fk:".to_vec();
11365        namespaced.extend_from_slice(key);
11366        self.acquire_txn_lock(
11367            txn_id,
11368            crate::locks::LockKey::key(table_id, namespaced),
11369            mode,
11370            control,
11371        )?;
11372        // The hook is cloned out before firing: holding the slot's mutex while
11373        // a parked hook waits would block every other commit's hook call.
11374        let hook = self.fk_lock_hook.lock().clone();
11375        if let Some(hook) = hook {
11376            hook();
11377        }
11378        Ok(())
11379    }
11380
11381    fn validate_constraints(
11382        &self,
11383        txn_id: u64,
11384        staging: &mut Vec<(u64, crate::txn::Staged)>,
11385        read_epoch: Epoch,
11386        principal: Option<&crate::auth::Principal>,
11387        control: Option<&crate::ExecutionControl>,
11388    ) -> Result<()> {
11389        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
11390        use crate::memtable::Row;
11391        use crate::txn::Staged;
11392        use std::collections::HashSet;
11393
11394        commit_prepare_checkpoint(control, 0)?;
11395        let snapshot = Snapshot::at(read_epoch);
11396        let cat = self.catalog.read().clone();
11397
11398        // Collect live (id, name, constraints-bearing?) for staged tables.
11399        let live: Vec<(u64, String, crate::schema::Schema)> = cat
11400            .tables
11401            .iter()
11402            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
11403            .map(|e| (e.table_id, e.name.clone(), e.schema.clone()))
11404            .collect();
11405
11406        // Fast path: bail if no live table declares any constraints at all.
11407        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
11408        if !any_constraints {
11409            self.materialize_generated_embeddings(staging, control)?;
11410            return Ok(());
11411        }
11412
11413        // Lazily-loaded visible rows per table, shared across checks.
11414        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
11415        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
11416            if let Some(r) = rows_cache.get(&table_id) {
11417                return Ok(r.clone());
11418            }
11419            let handle = self.table_by_id(table_id)?;
11420            let rows = match control {
11421                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
11422                None => handle.lock().visible_rows(snapshot)?,
11423            };
11424            rows_cache.insert(table_id, rows.clone());
11425            Ok(rows)
11426        };
11427
11428        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
11429        // carry an explicit old RowId + full new image. This makes action choice
11430        // reliable even when the referenced key itself changes; a delete+put
11431        // heuristic cannot distinguish that from unrelated operations.
11432        let mut processed_updates = HashSet::new();
11433        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
11434        let mut update_pass = 0_usize;
11435        loop {
11436            commit_prepare_checkpoint(control, update_pass)?;
11437            update_pass += 1;
11438            let updates: Vec<PendingUpdate> = staging
11439                .iter()
11440                .enumerate()
11441                .filter_map(|(index, (table_id, op))| match op {
11442                    Staged::Update {
11443                        row_id,
11444                        new_row: cells,
11445                        ..
11446                    } if !processed_updates.contains(&index) => {
11447                        Some((index, *table_id, *row_id, cells.clone()))
11448                    }
11449                    _ => None,
11450                })
11451                .collect();
11452            if updates.is_empty() {
11453                break;
11454            }
11455            let mut new_ops = Vec::new();
11456            for (update_index, (index, table_id, row_id, new_cells)) in
11457                updates.into_iter().enumerate()
11458            {
11459                commit_prepare_checkpoint(control, update_index)?;
11460                processed_updates.insert(index);
11461                let Some(tname) = live
11462                    .iter()
11463                    .find(|(id, _, _)| *id == table_id)
11464                    .map(|(_, name, _)| name.as_str())
11465                else {
11466                    continue;
11467                };
11468                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
11469                    continue;
11470                };
11471                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
11472                for (child_id, _child_name, child_schema) in &live {
11473                    for fk in &child_schema.constraints.foreign_keys {
11474                        if fk.ref_table != tname {
11475                            continue;
11476                        }
11477                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
11478                        else {
11479                            continue;
11480                        };
11481                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
11482                            == Some(old_key.as_slice())
11483                        {
11484                            continue;
11485                        }
11486                        if fk.on_update == FkAction::Restrict {
11487                            continue;
11488                        }
11489                        // S1B-003: the referenced key is being changed, so this
11490                        // update removes it for any action — hold an Exclusive
11491                        // parent-protection lock against concurrent child
11492                        // inserts referencing the old key.
11493                        self.acquire_fk_lock(
11494                            txn_id,
11495                            table_id,
11496                            &old_key,
11497                            crate::locks::LockMode::Exclusive,
11498                            control,
11499                        )?;
11500                        let child_rows = load_rows(*child_id)?;
11501                        for (child_index, child) in child_rows.into_iter().enumerate() {
11502                            commit_prepare_checkpoint(control, child_index)?;
11503                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
11504                                != Some(old_key.as_slice())
11505                            {
11506                                continue;
11507                            }
11508                            if staging.iter().any(|(id, op)| {
11509                                *id == *child_id
11510                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
11511                            }) {
11512                                continue;
11513                            }
11514                            let mut cells: Vec<(u16, Value)> = child
11515                                .columns
11516                                .iter()
11517                                .map(|(column_id, value)| (*column_id, value.clone()))
11518                                .collect();
11519                            for (child_column, parent_column) in
11520                                fk.columns.iter().zip(&fk.ref_columns)
11521                            {
11522                                cells.retain(|(column_id, _)| column_id != child_column);
11523                                let value = match fk.on_update {
11524                                    FkAction::Cascade => {
11525                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
11526                                    }
11527                                    FkAction::SetNull => Value::Null,
11528                                    FkAction::Restrict => {
11529                                        return Err(MongrelError::Other(
11530                                            "restricted foreign-key update reached cascade preparation"
11531                                                .into(),
11532                                        ));
11533                                    }
11534                                };
11535                                cells.push((*child_column, value));
11536                            }
11537                            cells.sort_by_key(|(column_id, _)| *column_id);
11538                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
11539                                *id == *child_id
11540                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
11541                            }) {
11542                                if let Staged::Update {
11543                                    new_row: existing,
11544                                    changed_columns,
11545                                    ..
11546                                } = &mut staging[existing_index].1 {
11547                                    changed_columns.extend(fk.columns.iter().copied());
11548                                    changed_columns.sort_unstable();
11549                                    changed_columns.dedup();
11550                                    if *existing != cells {
11551                                        *existing = cells;
11552                                        processed_updates.remove(&existing_index);
11553                                    }
11554                                }
11555                            } else {
11556                                new_ops.push((
11557                                    *child_id,
11558                                    Staged::Update {
11559                                        row_id: child.row_id,
11560                                        new_row: cells,
11561                                        changed_columns: fk.columns.clone(),
11562                                    },
11563                                ));
11564                            }
11565                        }
11566                    }
11567                }
11568            }
11569            staging.extend(new_ops);
11570        }
11571
11572        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
11573        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
11574        // enforced as a violation in Phase B. `cascaded` records every delete
11575        // we have already expanded so a self-referential CASCADE FK cannot loop.
11576        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
11577        let mut cascade_pass = 0_usize;
11578        loop {
11579            commit_prepare_checkpoint(control, cascade_pass)?;
11580            cascade_pass += 1;
11581            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
11582            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
11583                .iter()
11584                .filter_map(|(t, op)| match op {
11585                    Staged::Delete(rid) => Some((*t, *rid)),
11586                    _ => None,
11587                })
11588                .collect();
11589            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
11590                commit_prepare_checkpoint(control, delete_index)?;
11591                if !cascaded.insert((table_id, rid.0)) {
11592                    continue;
11593                }
11594                let Some(tname) = live
11595                    .iter()
11596                    .find(|(t, _, _)| *t == table_id)
11597                    .map(|(_, n, _)| n.as_str())
11598                else {
11599                    continue;
11600                };
11601                let parent_handle = self.table_by_id(table_id)?;
11602                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
11603                    continue;
11604                };
11605                for (child_id, _child_name, child_schema) in &live {
11606                    for fk in &child_schema.constraints.foreign_keys {
11607                        if fk.ref_table != tname {
11608                            continue;
11609                        }
11610                        let Some(parent_key) =
11611                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
11612                        else {
11613                            continue;
11614                        };
11615                        // Suppress ON DELETE cascade/set-null when this "delete"
11616                        // is actually half of an UPDATE encoded as Delete(old)+
11617                        // Put(new): if a staged Put in the SAME table still
11618                        // provides the referenced parent key, the parent still
11619                        // exists (its non-key columns changed) and the children
11620                        // must be left alone. A genuine delete, or an update
11621                        // that CHANGES the referenced key, has no preserving Put
11622                        // → cascade fires as before.
11623                        let key_preserved = staging.iter().any(|(t, op)| {
11624                            if *t != table_id {
11625                                return false;
11626                            }
11627                            let Staged::Put(cells) = op else {
11628                                return false;
11629                            };
11630                            let map: HashMap<u16, crate::memtable::Value> =
11631                                cells.iter().cloned().collect();
11632                            encode_composite_key(&fk.ref_columns, &map).as_deref()
11633                                == Some(parent_key.as_slice())
11634                        });
11635                        if key_preserved {
11636                            continue;
11637                        }
11638                        // S1B-003: the referenced parent key is genuinely being
11639                        // removed (delete, or the delete half of a key-changing
11640                        // update), for every FK action — hold an Exclusive
11641                        // parent-protection lock against concurrent child
11642                        // inserts referencing it. RESTRICT fks are enforced in
11643                        // Phase B; the claim covers them too.
11644                        self.acquire_fk_lock(
11645                            txn_id,
11646                            table_id,
11647                            &parent_key,
11648                            crate::locks::LockMode::Exclusive,
11649                            control,
11650                        )?;
11651                        match fk.on_delete {
11652                            FkAction::Restrict => continue,
11653                            FkAction::Cascade => {
11654                                let child_rows = load_rows(*child_id)?;
11655                                for (child_index, cr) in child_rows.iter().enumerate() {
11656                                    commit_prepare_checkpoint(control, child_index)?;
11657                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
11658                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
11659                                            == Some(parent_key.as_slice())
11660                                    {
11661                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
11662                                    }
11663                                }
11664                            }
11665                            FkAction::SetNull => {
11666                                let child_rows = load_rows(*child_id)?;
11667                                for (child_index, cr) in child_rows.iter().enumerate() {
11668                                    commit_prepare_checkpoint(control, child_index)?;
11669                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
11670                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
11671                                            == Some(parent_key.as_slice())
11672                                    {
11673                                        // Re-emit the child row with the FK
11674                                        // columns set to NULL (delete + put).
11675                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
11676                                            .columns
11677                                            .iter()
11678                                            .map(|(k, v)| (*k, v.clone()))
11679                                            .collect();
11680                                        for cid in &fk.columns {
11681                                            cells.retain(|(k, _)| k != cid);
11682                                            cells.push((*cid, crate::memtable::Value::Null));
11683                                        }
11684                                        new_ops.push((
11685                                            *child_id,
11686                                            Staged::Update {
11687                                                row_id: cr.row_id,
11688                                                new_row: cells,
11689                                                changed_columns: fk.columns.clone(),
11690                                            },
11691                                        ));
11692                                    }
11693                                }
11694                            }
11695                        }
11696                    }
11697                }
11698            }
11699            if new_ops.is_empty() {
11700                break;
11701            }
11702            staging.extend(new_ops);
11703        }
11704
11705        // Constraint actions can add writes. Re-authorize the final write set
11706        // before generated-value providers receive any row content, then
11707        // materialize vectors before Phase B validates the committed cells.
11708        self.validate_write_permissions(staging, principal, control)?;
11709        self.validate_security_writes(staging, read_epoch, principal, control)?;
11710        self.materialize_generated_embeddings(staging, control)?;
11711
11712        // Rows staged for deletion in THIS transaction (now including cascaded
11713        // deletes). Used to exclude the old version of an updated row from
11714        // unique-existence scans.
11715        let staged_deletes: HashSet<(u64, u64)> = staging
11716            .iter()
11717            .filter_map(|(t, op)| match op {
11718                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
11719                _ => None,
11720            })
11721            .collect();
11722
11723        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
11724        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
11725
11726        // ── Phase B: validate the fully-expanded staging set.
11727        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
11728            commit_prepare_checkpoint(control, operation_index)?;
11729            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id) else {
11730                continue;
11731            };
11732            let cells_map: HashMap<u16, crate::memtable::Value>;
11733            match op {
11734                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
11735                    cells_map = cells.iter().cloned().collect();
11736
11737                    // CHECK constraints.
11738                    if !schema.constraints.checks.is_empty() {
11739                        validate_checks(&schema.constraints.checks, &cells_map)?;
11740                    }
11741
11742                    // UNIQUE (non-PK) constraints.
11743                    for uc in &schema.constraints.uniques {
11744                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
11745                            continue; // NULL in a constrained column → skip (SQL).
11746                        };
11747                        let marker = (*table_id, uc.id, key.clone());
11748                        if !seen_unique.insert(marker) {
11749                            return Err(MongrelError::Conflict(format!(
11750                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
11751                                uc.name
11752                            )));
11753                        }
11754                        let rows = load_rows(*table_id)?;
11755                        for (row_index, r) in rows.iter().enumerate() {
11756                            commit_prepare_checkpoint(control, row_index)?;
11757                            // Skip rows this same transaction is deleting (the
11758                            // old version of an updated/cascade-deleted row).
11759                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
11760                                continue;
11761                            }
11762                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
11763                                if theirs == key {
11764                                    return Err(MongrelError::Conflict(format!(
11765                                        "UNIQUE constraint '{}' on table '{tname}' violated",
11766                                        uc.name
11767                                    )));
11768                                }
11769                            }
11770                        }
11771                    }
11772
11773                    // FK insert-side: parent must exist.
11774                    for fk in &schema.constraints.foreign_keys {
11775                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
11776                            continue; // NULL FK component → not checked (SQL).
11777                        };
11778                        let Some(parent_id) = cat
11779                            .tables
11780                            .iter()
11781                            .find(|t| t.name == fk.ref_table)
11782                            .map(|t| t.table_id)
11783                        else {
11784                            return Err(MongrelError::InvalidArgument(format!(
11785                                "FOREIGN KEY '{}' references unknown table '{}'",
11786                                fk.name, fk.ref_table
11787                            )));
11788                        };
11789                        // S1B-003: hold a Shared parent-protection lock on the
11790                        // referenced key while checking existence, so a
11791                        // concurrent parent delete or key-changing update
11792                        // serializes against this insert.
11793                        self.acquire_fk_lock(
11794                            txn_id,
11795                            parent_id,
11796                            &child_key,
11797                            crate::locks::LockMode::Shared,
11798                            control,
11799                        )?;
11800                        let parent_rows = load_rows(parent_id)?;
11801                        let mut found = false;
11802                        for (row_index, r) in parent_rows.iter().enumerate() {
11803                            commit_prepare_checkpoint(control, row_index)?;
11804                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
11805                                continue;
11806                            }
11807                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
11808                                if pkey == child_key {
11809                                    found = true;
11810                                    break;
11811                                }
11812                            }
11813                        }
11814                        // Final-write-set FK validation: a parent inserted in
11815                        // THIS transaction also satisfies the FK. This enables
11816                        // atomic parent+child batches and cyclical/mutual FK
11817                        // inserts within a single transaction — the child sees
11818                        // the staged parent put even though it is not committed
11819                        // yet.
11820                        if !found {
11821                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
11822                                commit_prepare_checkpoint(control, staged_index)?;
11823                                if *st_table != parent_id {
11824                                    continue;
11825                                }
11826                                if let Staged::Put(pcells)
11827                                | Staged::Update {
11828                                    new_row: pcells, ..
11829                                } = st_op
11830                                {
11831                                    let pmap: HashMap<u16, crate::memtable::Value> =
11832                                        pcells.iter().cloned().collect();
11833                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
11834                                    {
11835                                        if pkey == child_key {
11836                                            found = true;
11837                                            break;
11838                                        }
11839                                    }
11840                                }
11841                            }
11842                        }
11843                        if !found {
11844                            return Err(MongrelError::Conflict(format!(
11845                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
11846                                fk.name, fk.ref_table
11847                            )));
11848                        }
11849                    }
11850
11851                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
11852                    // expanded in Phase A; here the final child write set is
11853                    // known, so a child explicitly moved/deleted by this same
11854                    // transaction does not cause a false violation.
11855                    if let Staged::Update { row_id, .. } = op {
11856                        let parent_handle = self.table_by_id(*table_id)?;
11857                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
11858                            continue;
11859                        };
11860                        for (child_id, child_name, child_schema) in &live {
11861                            for fk in &child_schema.constraints.foreign_keys {
11862                                if fk.ref_table != *tname || fk.on_update != FkAction::Restrict {
11863                                    continue;
11864                                }
11865                                let Some(old_key) =
11866                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
11867                                else {
11868                                    continue;
11869                                };
11870                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
11871                                    == Some(old_key.as_slice())
11872                                {
11873                                    continue;
11874                                }
11875                                // S1B-003: the referenced key is being changed —
11876                                // hold an Exclusive parent-protection lock
11877                                // against concurrent child inserts referencing
11878                                // the old key.
11879                                self.acquire_fk_lock(
11880                                    txn_id,
11881                                    *table_id,
11882                                    &old_key,
11883                                    crate::locks::LockMode::Exclusive,
11884                                    control,
11885                                )?;
11886                                for (child_index, child) in
11887                                    load_rows(*child_id)?.into_iter().enumerate()
11888                                {
11889                                    commit_prepare_checkpoint(control, child_index)?;
11890                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
11891                                        != Some(old_key.as_slice())
11892                                    {
11893                                        continue;
11894                                    }
11895                                    let replacement = staging.iter().find_map(|(id, op)| {
11896                                        if *id != *child_id {
11897                                            return None;
11898                                        }
11899                                        match op {
11900                                            Staged::Delete(id) if *id == child.row_id => Some(None),
11901                                            Staged::Update {
11902                                                row_id,
11903                                                new_row: cells,
11904                                                ..
11905                                            } if *row_id == child.row_id => {
11906                                                let map: HashMap<u16, Value> =
11907                                                    cells.iter().cloned().collect();
11908                                                Some(encode_composite_key(&fk.columns, &map))
11909                                            }
11910                                            _ => None,
11911                                        }
11912                                    });
11913                                    if replacement.is_some_and(|key| {
11914                                        key.as_deref() != Some(old_key.as_slice())
11915                                    }) {
11916                                        continue;
11917                                    }
11918                                    return Err(MongrelError::Conflict(format!(
11919                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
11920                                        fk.name
11921                                    )));
11922                                }
11923                            }
11924                        }
11925                    }
11926                }
11927                Staged::Delete(rid) => {
11928                    // FK ON DELETE RESTRICT: a child row (whose FK action is
11929                    // RESTRICT) referencing this parent blocks the delete.
11930                    // CASCADE/SET NULL children were expanded in Phase A.
11931                    let parent_handle = self.table_by_id(*table_id)?;
11932                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
11933                        continue;
11934                    };
11935                    for (child_id, child_name, child_schema) in &live {
11936                        for fk in &child_schema.constraints.foreign_keys {
11937                            if fk.ref_table != *tname || fk.on_delete != FkAction::Restrict {
11938                                continue;
11939                            }
11940                            let Some(parent_key) =
11941                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
11942                            else {
11943                                continue;
11944                            };
11945                            let child_rows = load_rows(*child_id)?;
11946                            for (row_index, r) in child_rows.iter().enumerate() {
11947                                commit_prepare_checkpoint(control, row_index)?;
11948                                // A child already being deleted by this txn
11949                                // (cascade/inline) is not a restrict violation.
11950                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
11951                                    continue;
11952                                }
11953                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
11954                                    if ck == parent_key {
11955                                        return Err(MongrelError::Conflict(format!(
11956                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
11957                                            fk.name
11958                                        )));
11959                                    }
11960                                }
11961                            }
11962                        }
11963                    }
11964                }
11965                Staged::Truncate => {
11966                    // Truncate is RESTRICT-only: reject if any child references
11967                    // this table (any FK action), since cascade-truncate is
11968                    // unsupported.
11969                    for (child_id, child_name, child_schema) in &live {
11970                        for fk in &child_schema.constraints.foreign_keys {
11971                            if fk.ref_table != *tname {
11972                                continue;
11973                            }
11974                            let child_rows = load_rows(*child_id)?;
11975                            if child_rows
11976                                .iter()
11977                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
11978                            {
11979                                return Err(MongrelError::Conflict(format!(
11980                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
11981                                    fk.name
11982                                )));
11983                            }
11984                        }
11985                    }
11986                }
11987            }
11988        }
11989        Ok(())
11990    }
11991
11992    fn validate_write_permissions(
11993        &self,
11994        staging: &[(u64, crate::txn::Staged)],
11995        principal: Option<&crate::auth::Principal>,
11996        control: Option<&crate::ExecutionControl>,
11997    ) -> Result<()> {
11998        commit_prepare_checkpoint(control, 0)?;
11999        if principal.is_none() && !self.auth_state.require_auth() {
12000            return Ok(());
12001        }
12002        let principal = principal.ok_or(MongrelError::AuthRequired)?;
12003        let needs = summarize_write_permissions(staging);
12004        let catalog = self.catalog.read();
12005
12006        if needs.values().any(|need| need.truncate) {
12007            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
12008        }
12009        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
12010            commit_prepare_checkpoint(control, need_index)?;
12011            let entry = catalog
12012                .tables
12013                .iter()
12014                .find(|entry| {
12015                    entry.table_id == table_id
12016                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
12017                })
12018                .ok_or_else(|| {
12019                    MongrelError::NotFound(format!(
12020                        "live table {table_id} not found during write validation"
12021                    ))
12022                })?;
12023            if matches!(entry.state, TableState::Building { .. }) {
12024                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
12025                continue;
12026            }
12027            if need.insert {
12028                Self::require_columns_for_principal(
12029                    &entry.name,
12030                    &entry.schema,
12031                    crate::auth::ColumnOperation::Insert,
12032                    &need.insert_columns,
12033                    principal,
12034                )?;
12035            }
12036            if need.update {
12037                Self::require_columns_for_principal(
12038                    &entry.name,
12039                    &entry.schema,
12040                    crate::auth::ColumnOperation::Update,
12041                    &need.update_columns,
12042                    principal,
12043                )?;
12044            }
12045            if need.delete {
12046                self.require_for(
12047                    Some(principal),
12048                    &crate::auth::Permission::Delete {
12049                        table: entry.name.clone(),
12050                    },
12051                )?;
12052            }
12053        }
12054        Ok(())
12055    }
12056
12057    fn validate_security_writes(
12058        &self,
12059        staging: &[(u64, crate::txn::Staged)],
12060        read_epoch: Epoch,
12061        explicit_principal: Option<&crate::auth::Principal>,
12062        control: Option<&crate::ExecutionControl>,
12063    ) -> Result<()> {
12064        commit_prepare_checkpoint(control, 0)?;
12065        use crate::security::PolicyCommand;
12066        use crate::txn::Staged;
12067
12068        let catalog = self.catalog.read();
12069        if catalog.security.rls_tables.is_empty() {
12070            return Ok(());
12071        }
12072        let security = catalog.security.clone();
12073        let table_names = catalog
12074            .tables
12075            .iter()
12076            .filter(|entry| matches!(entry.state, TableState::Live))
12077            .map(|entry| (entry.table_id, entry.name.clone()))
12078            .collect::<HashMap<_, _>>();
12079        drop(catalog);
12080        if !staging.iter().any(|(table_id, _)| {
12081            table_names
12082                .get(table_id)
12083                .is_some_and(|table| security.rls_enabled(table))
12084        }) {
12085            return Ok(());
12086        }
12087        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
12088
12089        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
12090            commit_prepare_checkpoint(control, operation_index)?;
12091            let Some(table) = table_names.get(table_id) else {
12092                continue;
12093            };
12094            if !security.rls_enabled(table) || principal.is_admin {
12095                continue;
12096            }
12097            let denied = |command| MongrelError::PermissionDenied {
12098                required: match command {
12099                    PolicyCommand::Insert => crate::auth::Permission::Insert {
12100                        table: table.clone(),
12101                    },
12102                    PolicyCommand::Update => crate::auth::Permission::Update {
12103                        table: table.clone(),
12104                    },
12105                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
12106                        crate::auth::Permission::Delete {
12107                            table: table.clone(),
12108                        }
12109                    }
12110                },
12111                principal: principal.username.clone(),
12112            };
12113            match operation {
12114                Staged::Put(cells) => {
12115                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
12116                    row.columns.extend(cells.iter().cloned());
12117                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
12118                        return Err(denied(PolicyCommand::Insert));
12119                    }
12120                }
12121                Staged::Update {
12122                    row_id,
12123                    new_row: cells,
12124                    ..
12125                } => {
12126                    let old = self
12127                        .table_by_id(*table_id)?
12128                        .lock()
12129                        .get(*row_id, Snapshot::at(read_epoch))
12130                        .ok_or_else(|| {
12131                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12132                        })?;
12133                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
12134                        return Err(denied(PolicyCommand::Update));
12135                    }
12136                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
12137                    new.columns.extend(cells.iter().cloned());
12138                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
12139                        return Err(denied(PolicyCommand::Update));
12140                    }
12141                }
12142                Staged::Delete(row_id) => {
12143                    let old = self
12144                        .table_by_id(*table_id)?
12145                        .lock()
12146                        .get(*row_id, Snapshot::at(read_epoch))
12147                        .ok_or_else(|| {
12148                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12149                        })?;
12150                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
12151                        return Err(denied(PolicyCommand::Delete));
12152                    }
12153                }
12154                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
12155            }
12156        }
12157        Ok(())
12158    }
12159
12160    /// Seal a transaction (spec §9.3):
12161    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
12162    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
12163    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
12164    ///    group-sync, record conflict keys.
12165    /// 3. Publish — apply to tables, advance visible in-order.
12166    #[allow(clippy::too_many_arguments)]
12167    pub(crate) fn commit_transaction_with_external_states(
12168        &self,
12169        txn_id: u64,
12170        read_epoch: Epoch,
12171        staging: Vec<(u64, crate::txn::Staged)>,
12172        external_states: Vec<(String, Vec<u8>)>,
12173        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12174        security_principal: Option<crate::auth::Principal>,
12175        principal_catalog_bound: bool,
12176        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12177        context: crate::txn::TxnCommitContext,
12178    ) -> Result<(Epoch, Vec<RowId>)> {
12179        self.commit_transaction_with_external_states_inner(
12180            txn_id,
12181            read_epoch,
12182            staging,
12183            external_states,
12184            materialized_view_updates,
12185            security_principal,
12186            principal_catalog_bound,
12187            external_trigger_bridge,
12188            context,
12189            None,
12190            None,
12191        )
12192    }
12193
12194    #[allow(clippy::too_many_arguments)]
12195    pub(crate) fn commit_transaction_with_external_states_controlled(
12196        &self,
12197        txn_id: u64,
12198        read_epoch: Epoch,
12199        staging: Vec<(u64, crate::txn::Staged)>,
12200        external_states: Vec<(String, Vec<u8>)>,
12201        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12202        security_principal: Option<crate::auth::Principal>,
12203        principal_catalog_bound: bool,
12204        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12205        context: crate::txn::TxnCommitContext,
12206        control: &crate::ExecutionControl,
12207        before_commit: &mut dyn FnMut() -> Result<()>,
12208    ) -> Result<(Epoch, Vec<RowId>)> {
12209        self.commit_transaction_with_external_states_inner(
12210            txn_id,
12211            read_epoch,
12212            staging,
12213            external_states,
12214            materialized_view_updates,
12215            security_principal,
12216            principal_catalog_bound,
12217            external_trigger_bridge,
12218            context,
12219            Some(control),
12220            Some(before_commit),
12221        )
12222    }
12223
12224    #[allow(clippy::too_many_arguments)]
12225    fn commit_transaction_with_external_states_inner(
12226        &self,
12227        txn_id: u64,
12228        read_epoch: Epoch,
12229        mut staging: Vec<(u64, crate::txn::Staged)>,
12230        external_states: Vec<(String, Vec<u8>)>,
12231        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12232        mut security_principal: Option<crate::auth::Principal>,
12233        principal_catalog_bound: bool,
12234        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12235        context: crate::txn::TxnCommitContext,
12236        control: Option<&crate::ExecutionControl>,
12237        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
12238    ) -> Result<(Epoch, Vec<RowId>)> {
12239        use crate::memtable::Row;
12240        use crate::txn::{Staged, StagedOp, WriteKey};
12241        use crate::wal::Op;
12242        use std::collections::hash_map::DefaultHasher;
12243        use std::hash::{Hash, Hasher};
12244        use std::sync::atomic::Ordering;
12245
12246        if txn_id == crate::wal::SYSTEM_TXN_ID {
12247            return Err(MongrelError::Full(
12248                "per-open transaction id namespace exhausted; reopen the database".into(),
12249            ));
12250        }
12251        if self.read_only {
12252            return Err(MongrelError::ReadOnlyReplica);
12253        }
12254        commit_prepare_checkpoint(control, 0)?;
12255        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
12256        self.refresh_security_catalog_if_stale(observed_security_version)?;
12257        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
12258        if self.auth_state.require_auth() && security_principal.is_none() {
12259            return Err(MongrelError::AuthRequired);
12260        }
12261        {
12262            let catalog = self.catalog.read();
12263            if principal_catalog_bound
12264                || security_principal
12265                    .as_ref()
12266                    .is_some_and(|principal| principal.user_id != 0)
12267            {
12268                let principal = security_principal
12269                    .as_ref()
12270                    .ok_or(MongrelError::AuthRequired)?;
12271                security_principal =
12272                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
12273                if security_principal.is_none() {
12274                    return Err(MongrelError::AuthRequired);
12275                }
12276            }
12277        }
12278        let _replication_guard = self.replication_barrier.read();
12279        if self.poisoned.load(Ordering::Relaxed) {
12280            return Err(MongrelError::Other(
12281                "database poisoned by fsync error".into(),
12282            ));
12283        }
12284        // S1A-004: admit the commit as one core operation (rejects once the
12285        // core is draining, closing, closed, or lifecycle-poisoned; the legacy
12286        // fsync-poison error above still wins for a WAL-poisoned core).
12287        let _operation = self.admit_operation()?;
12288
12289        // ── S1B-003: user transactions hold the schema barrier Shared for the
12290        // whole commit so DDL (Exclusive) excludes concurrent DML. Internal
12291        // commits (catalog backfills, external-table state — `context.state`
12292        // is `None`) skip it: they run under their DDL operation's own
12293        // Exclusive hold, and acquiring a second, differently-keyed hold here
12294        // would self-deadlock. The guard releases every lock this commit
12295        // acquired — schema barrier, unique claims, sequence barriers, FK
12296        // holds — on every exit path (S1B-004 step 12).
12297        if context.state.is_some() {
12298            self.acquire_txn_lock(
12299                txn_id,
12300                crate::locks::LockKey::schema_barrier(),
12301                crate::locks::LockMode::Shared,
12302                control,
12303            )?;
12304        }
12305        let _txn_lock_guard = TxnLockGuard {
12306            locks: &self.lock_manager,
12307            txn_id,
12308        };
12309
12310        // ── S1B-005: idempotency check BEFORE the proposal. A repeated key
12311        // with an identical request replays the original receipt without
12312        // re-executing; a different request under the same key conflicts; a
12313        // new key is reserved durably before any WAL record can become
12314        // durable, so a crash mid-commit fails closed on retry.
12315        let idempotency_request = match &context.idempotency {
12316            Some(request) => match self.idempotency.check_and_reserve(request)? {
12317                crate::txn::IdempotencyCheck::Replay(receipt) => {
12318                    let epoch = Epoch(receipt.log_position.index);
12319                    if let Some(state) = &context.state {
12320                        state.committed(receipt);
12321                    }
12322                    return Ok((epoch, Vec::new()));
12323                }
12324                crate::txn::IdempotencyCheck::Reserved => Some(request.clone()),
12325            },
12326            None => None,
12327        };
12328        // Release the reservation on every pre-receipt failure path.
12329        let mut idempotency_guard = idempotency_request.as_ref().map(|request| {
12330            crate::txn::IdempotencyReservationGuard::new(&self.idempotency, request.clone())
12331        });
12332        let mut external_states = dedup_external_states(external_states);
12333        if !external_states.is_empty() {
12334            let cat = self.catalog.read();
12335            for (name, _) in &external_states {
12336                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
12337                    return Err(MongrelError::NotFound(format!(
12338                        "external table {name:?} not found"
12339                    )));
12340                }
12341            }
12342        }
12343        let prepared_materialized_views = {
12344            let mut deduplicated = HashMap::new();
12345            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
12346            {
12347                commit_prepare_checkpoint(control, definition_index)?;
12348                if definition.name.is_empty() || definition.query.trim().is_empty() {
12349                    return Err(MongrelError::InvalidArgument(
12350                        "materialized view name and query must not be empty".into(),
12351                    ));
12352                }
12353                deduplicated.insert(definition.name.clone(), definition);
12354            }
12355            let catalog = self.catalog.read();
12356            let mut prepared = Vec::with_capacity(deduplicated.len());
12357            for (definition_index, definition) in deduplicated.into_values().enumerate() {
12358                commit_prepare_checkpoint(control, definition_index)?;
12359                let table_id = catalog
12360                    .live(&definition.name)
12361                    .ok_or_else(|| {
12362                        MongrelError::NotFound(format!(
12363                            "materialized view table {:?} not found",
12364                            definition.name
12365                        ))
12366                    })?
12367                    .table_id;
12368                prepared.push((table_id, definition));
12369            }
12370            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
12371            prepared
12372        };
12373
12374        // ── 1. Prepare: fill generated values, expand triggers, validate, then
12375        // derive write keys from the final atomic write set.
12376        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12377        self.expand_table_triggers(
12378            txn_id,
12379            &mut staging,
12380            read_epoch,
12381            external_trigger_bridge,
12382            &mut external_states,
12383            control,
12384        )?;
12385        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12386        external_states = dedup_external_states(external_states);
12387        let expected_external_generations = {
12388            let catalog = self.catalog.read();
12389            let mut generations = HashMap::with_capacity(external_states.len());
12390            for (name, _) in &external_states {
12391                let entry = catalog
12392                    .external_tables
12393                    .iter()
12394                    .find(|entry| entry.name == *name)
12395                    .ok_or_else(|| {
12396                        MongrelError::NotFound(format!("external table {name:?} not found"))
12397                    })?;
12398                generations.insert(name.clone(), entry.created_epoch);
12399            }
12400            generations
12401        };
12402
12403        // S1B-003: claim every unique key this transaction inserts (primary
12404        // keys and declared UNIQUE constraints) in Exclusive mode BEFORE
12405        // validation reads. Concurrent inserts of the same key serialize on
12406        // the claim instead of racing to a write-write conflict; the
12407        // first-committer-wins index below remains the safety net.
12408        self.acquire_unique_key_claims(txn_id, &staging, control)?;
12409        // Fail unauthorized source writes before constraint expansion reads
12410        // existing rows. Constraint-generated writes are checked again inside
12411        // `validate_constraints` before any embedding provider is invoked.
12412        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
12413        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
12414        // Validate declarative constraints (unique / FK / check) under the read
12415        // snapshot, outside the WAL mutex. Trigger-produced writes are included
12416        // here, so the batch either satisfies every declared constraint or is
12417        // rejected atomically. This also materializes generated vectors after
12418        // all trigger and constraint-action writes exist, but before WAL append.
12419        self.validate_constraints(
12420            txn_id,
12421            &mut staging,
12422            read_epoch,
12423            security_principal.as_ref(),
12424            control,
12425        )?;
12426        let mut normalized = Vec::with_capacity(staging.len() * 2);
12427        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
12428            commit_prepare_checkpoint(control, staged_index)?;
12429            match op {
12430                crate::txn::Staged::Update {
12431                    row_id,
12432                    new_row: cells,
12433                    ..
12434                } => {
12435                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
12436                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
12437                }
12438                op => normalized.push((table_id, op)),
12439            }
12440        }
12441        staging = normalized;
12442        let has_changes = !staging.is_empty()
12443            || !external_states.is_empty()
12444            || !prepared_materialized_views.is_empty();
12445        let truncated_tables: HashSet<u64> = staging
12446            .iter()
12447            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
12448            .collect();
12449
12450        let write_keys = {
12451            let cat = self.catalog.read();
12452            let mut keys: Vec<WriteKey> = Vec::new();
12453            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12454                commit_prepare_checkpoint(control, staged_index)?;
12455                match staged {
12456                    Staged::Put(cells) => {
12457                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
12458                            for col in &entry.schema.columns {
12459                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
12460                                    if let Some((_, val)) =
12461                                        cells.iter().find(|(id, _)| *id == col.id)
12462                                    {
12463                                        let mut h = DefaultHasher::new();
12464                                        val.encode_key().hash(&mut h);
12465                                        keys.push(WriteKey::Unique {
12466                                            table_id: *table_id,
12467                                            index_id: 0,
12468                                            key_hash: h.finish(),
12469                                        });
12470                                    }
12471                                }
12472                            }
12473                            // Declared non-PK unique constraints register a
12474                            // `WriteKey::Unique` (namespace-separated from the
12475                            // PK's index_id==0 by setting the high bit) so two
12476                            // concurrent transactions inserting the same key
12477                            // cannot both commit. Rows with any NULL constrained
12478                            // column are skipped (SQL semantics).
12479                            for uc in &entry.schema.constraints.uniques {
12480                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
12481                                    &uc.columns,
12482                                    &cells.iter().cloned().collect(),
12483                                ) {
12484                                    let mut h = DefaultHasher::new();
12485                                    key_bytes.hash(&mut h);
12486                                    keys.push(WriteKey::Unique {
12487                                        table_id: *table_id,
12488                                        index_id: uc.id | 0x8000,
12489                                        key_hash: h.finish(),
12490                                    });
12491                                }
12492                            }
12493                        }
12494                    }
12495                    Staged::Delete(rid) => keys.push(WriteKey::Row {
12496                        table_id: *table_id,
12497                        row_id: rid.0,
12498                    }),
12499                    Staged::Truncate => keys.push(WriteKey::Table {
12500                        table_id: *table_id,
12501                    }),
12502                    Staged::Update { .. } => {
12503                        return Err(MongrelError::Other(
12504                            "transaction contains an unnormalized update during preparation".into(),
12505                        ));
12506                    }
12507                }
12508            }
12509            for (external_index, (name, _)) in external_states.iter().enumerate() {
12510                commit_prepare_checkpoint(control, external_index)?;
12511                let mut h = DefaultHasher::new();
12512                name.hash(&mut h);
12513                keys.push(WriteKey::Unique {
12514                    table_id: EXTERNAL_TABLE_ID,
12515                    index_id: 0,
12516                    key_hash: h.finish(),
12517                });
12518            }
12519            keys
12520        };
12521
12522        // Opportunistic pruning.
12523        let min_active = self.active_txns.min_read_epoch();
12524        if min_active < u64::MAX {
12525            self.conflicts.prune_below(Epoch(min_active));
12526        }
12527
12528        // S1B-002 (Serializable): SSI certification keys — every tracked point
12529        // read as a row key, every tracked predicate/range read as a table
12530        // key. A concurrent commit covering any of them after this
12531        // transaction's read epoch is an rw-antidependency dangerous
12532        // structure; certification aborts rather than allowing a
12533        // non-serializable interleaving.
12534        let ssi_keys = match context.isolation.canonical() {
12535            crate::txn::IsolationLevel::Serializable => {
12536                crate::txn::ssi_validation_keys(&context.read_set, &context.predicate_set)
12537            }
12538            _ => Vec::new(),
12539        };
12540
12541        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
12542        // §8.5, review fix #17). Snapshot the conflict-index version so the
12543        // sequencer only re-checks if new commits arrived in the interim.
12544        if self.conflicts.conflicts(&write_keys, read_epoch) {
12545            return Err(MongrelError::Conflict(
12546                "write-write conflict (pre-validate, first-committer-wins)".into(),
12547            ));
12548        }
12549        if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
12550            return Err(MongrelError::SerializationFailure {
12551                message: "a concurrent commit invalidated this transaction's reads (pre-validate)"
12552                    .into(),
12553            });
12554        }
12555        let pre_validate_version = self.conflicts.version();
12556
12557        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
12558        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
12559        // streamed as Put records; they are linked at publish time.
12560        let mut spilled: Vec<SpilledRun> = Vec::new();
12561        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
12562        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
12563        // as the spill runs are live (registered on first spill, dropped at the
12564        // end of this function on commit/abort/error).
12565        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
12566        {
12567            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
12568            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
12569            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12570                commit_prepare_checkpoint(control, staged_index)?;
12571                if let Staged::Put(cells) = staged {
12572                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
12573                        bytes.saturating_add(value.estimated_bytes())
12574                    });
12575                    let table_bytes = table_bytes.entry(*table_id).or_default();
12576                    *table_bytes = table_bytes.saturating_add(bytes);
12577                    put_indexes.entry(*table_id).or_default().push(staged_index);
12578                }
12579            }
12580            let tables = self.tables.read();
12581            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
12582                commit_prepare_checkpoint(control, table_index)?;
12583                if bytes
12584                    <= self
12585                        .spill_threshold
12586                        .load(std::sync::atomic::Ordering::Relaxed)
12587                {
12588                    continue;
12589                }
12590                let Some(handle) = tables.get(&table_id) else {
12591                    continue;
12592                };
12593                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
12594                let mut t = handle.lock();
12595                let tdir = t.table_dir().to_path_buf();
12596                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
12597                std::fs::create_dir_all(&txn_dir)?;
12598                let run_id = t.alloc_run_id()? as u128;
12599                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
12600                let final_path = t.run_path(run_id as u64);
12601
12602                let mut rows: Vec<Row> = Vec::new();
12603                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
12604                    commit_prepare_checkpoint(control, put_index)?;
12605                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
12606                        return Err(MongrelError::Other(
12607                            "transaction put index no longer references a put".into(),
12608                        ));
12609                    };
12610                    t.validate_cells_not_null(cells)?;
12611                    let row_id = t.alloc_row_id()?;
12612                    let mut row = Row::new(row_id, Epoch(0));
12613                    row.columns.extend(std::mem::take(cells));
12614                    rows.push(row);
12615                }
12616                let schema = t.schema_ref().clone();
12617                let kek = t.kek_ref().cloned();
12618                let specs = t.indexable_column_specs();
12619                drop(t);
12620
12621                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
12622                    .uniform_epoch(true);
12623                if let Some(ref kek) = kek {
12624                    writer = writer.with_encryption(kek.as_ref(), specs);
12625                }
12626                commit_prepare_checkpoint(control, 0)?;
12627                let header = writer.write(&pending_path, &rows)?;
12628                commit_prepare_checkpoint(control, 0)?;
12629                let row_count = header.row_count;
12630                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
12631                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
12632
12633                spilled.push(SpilledRun {
12634                    table_id,
12635                    run_id,
12636                    pending_path,
12637                    final_path,
12638                    rows,
12639                    row_count,
12640                    min_rid,
12641                    max_rid,
12642                    content_hash: header.content_hash,
12643                });
12644                spilled_tables.insert(table_id);
12645            }
12646        }
12647
12648        // Test seam: let a test race `gc()` against this in-flight spill.
12649        if spill_guard.is_some() {
12650            if let Some(hook) = self.spill_hook.lock().as_ref() {
12651                hook();
12652            }
12653        }
12654
12655        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
12656        // Allocating row ids + building the rows here (lock order: table handle →
12657        // nothing) means the sequencer never locks a table handle while holding
12658        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
12659        // the table handle THEN the shared WAL; if the sequencer did the reverse
12660        // (WAL then handle) the two paths would deadlock (review fix: B1).
12661        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
12662        // Row ids are allocated here, before the sequencer's delta conflict
12663        // re-check, so a losing txn leaks the ids it reserved — harmless, the
12664        // u64 row-id space is monotonic and gaps are expected (spills do the same).
12665        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
12666            .take(staging.len())
12667            .collect();
12668        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
12669            .take(staging.len())
12670            .collect();
12671        {
12672            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
12673            for (index, (table_id, staged)) in staging.iter().enumerate() {
12674                commit_prepare_checkpoint(control, index)?;
12675                if matches!(staged, Staged::Delete(_))
12676                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
12677                {
12678                    indexes_by_table.entry(*table_id).or_default().push(index);
12679                }
12680            }
12681            let tables = self.tables.read();
12682            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
12683                commit_prepare_checkpoint(control, table_index)?;
12684                let handle = tables.get(&table_id).ok_or_else(|| {
12685                    MongrelError::NotFound(format!("table {table_id} not mounted"))
12686                })?;
12687                #[cfg(test)]
12688                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
12689                let mut t = handle.lock();
12690                for (prepare_index, index) in indexes.into_iter().enumerate() {
12691                    commit_prepare_checkpoint(control, prepare_index)?;
12692                    match &staging[index].1 {
12693                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
12694                            t.validate_cells_not_null(cells)?;
12695                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
12696                            for (column, value) in cells {
12697                                row.columns.insert(*column, value.clone());
12698                            }
12699                            prebuilt[index] = Some(row);
12700                        }
12701                        Staged::Delete(row_id) => {
12702                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
12703                        }
12704                        Staged::Put(_) | Staged::Truncate => {}
12705                        Staged::Update { .. } => {
12706                            return Err(MongrelError::Other(
12707                                "transaction contains an unnormalized update during row preparation"
12708                                    .into(),
12709                            ));
12710                        }
12711                    }
12712                }
12713            }
12714        }
12715
12716        // Finish every fallible index read before the commit marker can become
12717        // durable. Post-durable row/run metadata application is then entirely
12718        // in-memory and cannot stop halfway through a multi-table publish.
12719        let prepared_table_handles = {
12720            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
12721            let put_table_ids: HashSet<u64> = staging
12722                .iter()
12723                .filter_map(|(table_id, staged)| {
12724                    matches!(staged, Staged::Put(_)).then_some(*table_id)
12725                })
12726                .collect();
12727            let tables = self.tables.read();
12728            let mut handles = HashMap::with_capacity(table_ids.len());
12729            for (table_index, table_id) in table_ids.into_iter().enumerate() {
12730                commit_prepare_checkpoint(control, table_index)?;
12731                let handle = tables.get(&table_id).ok_or_else(|| {
12732                    MongrelError::NotFound(format!("table {table_id} not mounted"))
12733                })?;
12734                if put_table_ids.contains(&table_id) {
12735                    match control {
12736                        Some(control) => {
12737                            handle.lock().prepare_durable_publish_controlled(control)?
12738                        }
12739                        None => handle.lock().prepare_durable_publish()?,
12740                    }
12741                }
12742                handles.insert(table_id, handle.clone());
12743            }
12744            handles
12745        };
12746
12747        // Link large-transaction spill files before WAL durability. The guard
12748        // restores their pending names on every error before WAL append begins;
12749        // publication only attaches already-present files in memory.
12750        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
12751
12752        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
12753            .iter()
12754            .map(|run| {
12755                (
12756                    run.table_id,
12757                    run.rows.iter().map(|row| row.row_id).collect(),
12758                )
12759            })
12760            .collect();
12761        let committed_row_ids = staging
12762            .iter()
12763            .enumerate()
12764            .filter_map(|(index, (table_id, staged))| {
12765                if !matches!(staged, Staged::Put(_)) {
12766                    return None;
12767                }
12768                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
12769                    spilled_row_ids
12770                        .get_mut(table_id)
12771                        .and_then(VecDeque::pop_front)
12772                })
12773            })
12774            .collect();
12775
12776        let mut prepared_external = Vec::with_capacity(external_states.len());
12777        for (external_index, (name, state)) in external_states.iter().enumerate() {
12778            commit_prepare_checkpoint(control, external_index)?;
12779            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
12780            prepared_external.push((name.clone(), state.clone(), pending));
12781        }
12782
12783        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
12784        //
12785        // The schema barrier's Shared hold covered the prepare/validate phases
12786        // above (every catalog, constraint, and claim read ran under a stable
12787        // schema); the publish below is guarded by the catalog-generation
12788        // check, exactly as before S1B-003. Release the barrier here — before
12789        // the sequencer — so a DDL waiting on its Exclusive hold may proceed
12790        // while this commit publishes (the generation check resolves that
12791        // race). Unique/sequence/FK claims stay held until the commit ends.
12792        if context.state.is_some() {
12793            self.lock_manager
12794                .release(txn_id, &crate::locks::LockKey::schema_barrier());
12795        }
12796        let added_runs: Vec<crate::wal::AddedRun> = spilled
12797            .iter()
12798            .map(|s| crate::wal::AddedRun {
12799                table_id: s.table_id,
12800                run_id: s.run_id,
12801                row_count: s.row_count,
12802                level: 0,
12803                min_row_id: s.min_rid,
12804                max_row_id: s.max_rid,
12805                content_hash: s.content_hash,
12806            })
12807            .collect();
12808        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
12809            hook();
12810        }
12811        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
12812        // Security mutations cannot overtake an authorized commit before its
12813        // commit marker is durable.
12814        let security_guard = self.security_coordinator.gate.read();
12815        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
12816            return Err(MongrelError::Conflict(
12817                "security policy changed during write".into(),
12818            ));
12819        }
12820        if spill_guard.is_some() {
12821            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
12822                hook();
12823            }
12824        }
12825        let commit_guard = self.commit_lock.lock();
12826        let catalog_generation_result = (|| {
12827            {
12828                let catalog = self.catalog.read();
12829                for table_id in prepared_table_handles.keys() {
12830                    let is_current = catalog.tables.iter().any(|entry| {
12831                        entry.table_id == *table_id
12832                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
12833                    });
12834                    if !is_current {
12835                        return Err(MongrelError::Conflict(format!(
12836                            "table {table_id} changed during transaction preparation"
12837                        )));
12838                    }
12839                }
12840                for (name, created_epoch) in &expected_external_generations {
12841                    let current = catalog
12842                        .external_tables
12843                        .iter()
12844                        .find(|entry| entry.name == *name)
12845                        .map(|entry| entry.created_epoch);
12846                    if current != Some(*created_epoch) {
12847                        return Err(MongrelError::Conflict(format!(
12848                            "external table {name:?} changed during transaction preparation"
12849                        )));
12850                    }
12851                }
12852                for (table_id, definition) in &prepared_materialized_views {
12853                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
12854                    if current != Some(*table_id) {
12855                        return Err(MongrelError::Conflict(format!(
12856                            "materialized view {:?} changed during transaction preparation",
12857                            definition.name
12858                        )));
12859                    }
12860                }
12861                if trigger_catalog_binding(&catalog) != trigger_binding {
12862                    return Err(MongrelError::Conflict(
12863                        "trigger or referenced table generation changed during transaction preparation"
12864                            .into(),
12865                    ));
12866                }
12867            }
12868            let tables = self.tables.read();
12869            for (table_id, prepared) in &prepared_table_handles {
12870                if !tables
12871                    .get(table_id)
12872                    .is_some_and(|current| current.ptr_eq(prepared))
12873                {
12874                    return Err(MongrelError::Conflict(format!(
12875                        "table {table_id} mount changed during transaction preparation"
12876                    )));
12877                }
12878            }
12879            Ok(())
12880        })();
12881        if let Err(error) = catalog_generation_result {
12882            drop(commit_guard);
12883            for (_, _, pending) in &prepared_external {
12884                let _ = std::fs::remove_file(pending);
12885            }
12886            return Err(error);
12887        }
12888        // The commit lock keeps the next epoch stable while logical spill
12889        // records are serialized. Build them before taking the shared WAL
12890        // lock, and cap their aggregate memory/WAL footprint.
12891        let new_epoch = self.epoch.assigned().next();
12892        let mut spilled_wal_bytes = 0;
12893        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
12894        let spill_prepare = (|| {
12895            for run in &mut spilled {
12896                for row in &mut run.rows {
12897                    row.committed_epoch = new_epoch;
12898                }
12899                for rows in encode_spilled_row_chunks(
12900                    &run.rows,
12901                    &mut spilled_wal_bytes,
12902                    SPILLED_WAL_TOTAL_MAX_BYTES,
12903                    control,
12904                )? {
12905                    spilled_wal_records.push((
12906                        run.table_id,
12907                        Op::SpilledRows {
12908                            table_id: run.table_id,
12909                            rows,
12910                        },
12911                    ));
12912                }
12913            }
12914            Result::<()>::Ok(())
12915        })();
12916        if let Err(error) = spill_prepare {
12917            for (_, _, pending) in &prepared_external {
12918                let _ = std::fs::remove_file(pending);
12919            }
12920            return Err(error);
12921        }
12922        let (
12923            new_epoch,
12924            mut _epoch_guard,
12925            applies,
12926            committed_materialized_views,
12927            commit_seq,
12928            commit_ts,
12929        ) = {
12930            let mut wal = self.shared_wal.lock();
12931
12932            // Re-check only if the conflict index advanced since pre-validation
12933            // (bounded delta — spec §8.5, review fix #17). If the version is
12934            // unchanged, the pre-check result is still valid and the sequencer
12935            // does O(1) work regardless of write-set size.
12936            if self.conflicts.version() != pre_validate_version {
12937                if self.conflicts.conflicts(&write_keys, read_epoch) {
12938                    // Abort: this txn assigned no epoch yet. The prepared-run guard
12939                    // restores final run names to their pending paths on return.
12940                    drop(wal);
12941                    for (_, _, pending) in &prepared_external {
12942                        let _ = std::fs::remove_file(pending);
12943                    }
12944                    return Err(MongrelError::Conflict(
12945                        "write-write conflict (sequencer delta re-check)".into(),
12946                    ));
12947                }
12948                if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
12949                    // S1B-002 dangerous structure: a commit that landed between
12950                    // the pre-check and the sequencer invalidated a tracked
12951                    // read. Abort before assigning any epoch.
12952                    drop(wal);
12953                    for (_, _, pending) in &prepared_external {
12954                        let _ = std::fs::remove_file(pending);
12955                    }
12956                    return Err(MongrelError::SerializationFailure {
12957                        message:
12958                            "a concurrent commit invalidated this transaction's reads (sequencer re-check)"
12959                                .into(),
12960                    });
12961                }
12962            }
12963
12964            if let Some(control) = control {
12965                if let Err(error) = control.checkpoint() {
12966                    drop(wal);
12967                    for (_, _, pending) in &prepared_external {
12968                        let _ = std::fs::remove_file(pending);
12969                    }
12970                    return Err(error);
12971                }
12972            }
12973            let mut applies = Vec::<TableApplyBatch>::new();
12974            let mut apply_indexes = HashMap::<u64, usize>::new();
12975            let mut committed_materialized_views = Vec::new();
12976            let mut wal_records = spilled_wal_records;
12977
12978            let mut index = 0;
12979            while index < staging.len() {
12980                let table_id = staging[index].0;
12981                let handle = prepared_table_handles
12982                    .get(&table_id)
12983                    .cloned()
12984                    .ok_or_else(|| {
12985                        MongrelError::NotFound(format!("table {table_id} not prepared"))
12986                    })?;
12987                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
12988                    let index = applies.len();
12989                    applies.push(TableApplyBatch {
12990                        table_id,
12991                        handle,
12992                        ops: Vec::new(),
12993                    });
12994                    index
12995                });
12996
12997                // Skip puts for tables that were spilled — their data is in a
12998                // pending run, not in streamed Put records.
12999                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
13000                {
13001                    index += 1;
13002                    continue;
13003                }
13004
13005                match &staging[index].1 {
13006                    Staged::Put(_) => {
13007                        let mut rows = Vec::new();
13008                        while index < staging.len()
13009                            && staging[index].0 == table_id
13010                            && matches!(&staging[index].1, Staged::Put(_))
13011                        {
13012                            let mut row = prebuilt[index].take().ok_or_else(|| {
13013                                MongrelError::Other(
13014                                    "transaction prepare lost a prebuilt put row".into(),
13015                                )
13016                            })?;
13017                            row.committed_epoch = new_epoch;
13018                            rows.push(row);
13019                            index += 1;
13020                        }
13021                        let payload = bincode::serialize(&rows)
13022                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
13023                        wal_records.push((
13024                            table_id,
13025                            Op::Put {
13026                                table_id,
13027                                rows: payload,
13028                            },
13029                        ));
13030                        applies[batch_index].ops.push(StagedOp::Put(rows));
13031                    }
13032                    Staged::Delete(_) => {
13033                        let mut row_ids = Vec::new();
13034                        while index < staging.len()
13035                            && staging[index].0 == table_id
13036                            && matches!(&staging[index].1, Staged::Delete(_))
13037                        {
13038                            let Staged::Delete(row_id) = &staging[index].1 else {
13039                                return Err(MongrelError::Other(
13040                                    "transaction delete batch changed during WAL preparation"
13041                                        .into(),
13042                                ));
13043                            };
13044                            if let Some(before) = &delete_images[index] {
13045                                wal_records.push((
13046                                    table_id,
13047                                    Op::BeforeImage {
13048                                        table_id,
13049                                        row_id: *row_id,
13050                                        row: bincode::serialize(before).map_err(|error| {
13051                                            MongrelError::Other(format!(
13052                                                "before-image serialize: {error}"
13053                                            ))
13054                                        })?,
13055                                    },
13056                                ));
13057                            }
13058                            row_ids.push(*row_id);
13059                            index += 1;
13060                        }
13061                        wal_records.push((
13062                            table_id,
13063                            Op::Delete {
13064                                table_id,
13065                                row_ids: row_ids.clone(),
13066                            },
13067                        ));
13068                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
13069                    }
13070                    Staged::Truncate => {
13071                        wal_records.push((table_id, Op::TruncateTable { table_id }));
13072                        applies[batch_index].ops.push(StagedOp::Truncate);
13073                        index += 1;
13074                    }
13075                    Staged::Update { .. } => {
13076                        return Err(MongrelError::Other(
13077                            "transaction contains an unnormalized update at the sequencer".into(),
13078                        ));
13079                    }
13080                }
13081            }
13082
13083            for (name, state, _) in &prepared_external {
13084                wal_records.push((
13085                    EXTERNAL_TABLE_ID,
13086                    Op::ExternalTableState {
13087                        name: name.clone(),
13088                        state: state.clone(),
13089                    },
13090                ));
13091            }
13092
13093            for (table_id, definition) in &prepared_materialized_views {
13094                let mut definition = definition.clone();
13095                definition.last_refresh_epoch = new_epoch.0;
13096                wal_records.push((
13097                    *table_id,
13098                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
13099                        name: definition.name.clone(),
13100                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
13101                    }),
13102                ));
13103                committed_materialized_views.push(definition);
13104            }
13105            if !committed_materialized_views.is_empty() {
13106                let mut next_catalog = self.catalog.read().clone();
13107                for definition in &committed_materialized_views {
13108                    if let Some(existing) = next_catalog
13109                        .materialized_views
13110                        .iter_mut()
13111                        .find(|existing| existing.name == definition.name)
13112                    {
13113                        *existing = definition.clone();
13114                    } else {
13115                        next_catalog.materialized_views.push(definition.clone());
13116                    }
13117                }
13118                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13119                wal_records.push((
13120                    WAL_TABLE_ID,
13121                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
13122                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
13123                    }),
13124                ));
13125            }
13126
13127            if let Some(control) = control {
13128                if let Err(error) = control.checkpoint() {
13129                    drop(wal);
13130                    for (_, _, pending) in &prepared_external {
13131                        let _ = std::fs::remove_file(pending);
13132                    }
13133                    return Err(error);
13134                }
13135            }
13136            if let Some(before_commit) = before_commit.as_mut() {
13137                if let Err(error) = before_commit() {
13138                    drop(wal);
13139                    for (_, _, pending) in &prepared_external {
13140                        let _ = std::fs::remove_file(pending);
13141                    }
13142                    return Err(error);
13143                }
13144            }
13145
13146            let assigned_epoch = self.epoch.bump_assigned();
13147            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
13148            if assigned_epoch != new_epoch {
13149                for (_, _, pending) in &prepared_external {
13150                    let _ = std::fs::remove_file(pending);
13151                }
13152                return Err(MongrelError::Conflict(
13153                    "commit epoch changed while sequencer lock was held".into(),
13154                ));
13155            }
13156
13157            // S1B-004 steps 5–6: assign the commit timestamp (spec §8.2:
13158            // strictly greater than every participant read/write timestamp of
13159            // this transaction — the transaction's HLC read timestamp captured
13160            // at `begin` is threaded through the commit context) and enter
13161            // commit-critical state before the proposal can become durable.
13162            // Internal commits with no transaction timestamp allocate from the
13163            // fail-closed clock gate instead.
13164            let commit_ts = match context.read_ts {
13165                Some(read_ts) => self.hlc.commit_timestamp([read_ts]),
13166                None => self.hlc.now().map_err(|skew| {
13167                    MongrelError::Other(format!(
13168                        "clock skew rejected commit timestamp allocation: {skew}"
13169                    ))
13170                })?,
13171            };
13172            // FND-006: `txn.decision.before` fires immediately before the
13173            // durable commit decision (enter CommitCritical / WAL proposal).
13174            // A Fail aborts while still Preparing — nothing durable yet.
13175            if let Err(fault) = mongreldb_fault::inject("txn.decision.before") {
13176                for (_, _, pending) in &prepared_external {
13177                    let _ = std::fs::remove_file(pending);
13178                }
13179                return Err(crate::commit_log::fault_as_io(fault));
13180            }
13181            if let Some(state) = &context.state {
13182                state.enter_commit_critical();
13183            }
13184
13185            // From this point the outcome can become ambiguous. Keep prepared
13186            // spill files at the final names referenced by a possibly durable
13187            // commit marker; orphan cleanup is safe when the append did fail.
13188            prepared_run_links.disarm();
13189
13190            // FND-004 (spec §9.4): the commit log owns the append step for the
13191            // transaction command (records + commit marker). The commit path
13192            // proposes through it; durability and the receipt follow below.
13193            let commit_seq = self
13194                .standalone_commit_log
13195                .append_transaction(
13196                    &mut wal,
13197                    txn_id,
13198                    new_epoch,
13199                    commit_ts,
13200                    wal_records,
13201                    &added_runs,
13202                )
13203                .map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
13204
13205            // Record the conflict + assign the epoch under the WAL lock so commit
13206            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
13207            // moves out of this critical section to the group-commit coordinator
13208            // so concurrent committers share a single leader fsync.
13209            self.conflicts.record(&write_keys, new_epoch);
13210            (
13211                new_epoch,
13212                _epoch_guard,
13213                applies,
13214                committed_materialized_views,
13215                commit_seq,
13216                commit_ts,
13217            )
13218        };
13219        drop(commit_guard);
13220
13221        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
13222        let receipt =
13223            self.await_durable_commit_with_ts(txn_id, commit_seq, new_epoch, commit_ts)?;
13224        drop(security_guard);
13225
13226        // ── S1B-005: record the receipt against the reserved idempotency key
13227        // (durable before the caller can observe success), so a repeated key
13228        // with an identical request replays this receipt.
13229        if let Some(request) = &idempotency_request {
13230            if let Err(error) =
13231                self.idempotency
13232                    .complete(request, txn_id, new_epoch, receipt.commit_ts)
13233            {
13234                return Err(MongrelError::DurableCommit {
13235                    epoch: new_epoch.0,
13236                    message: format!("idempotency record was not durable: {error}"),
13237                });
13238            }
13239            if let Some(guard) = idempotency_guard.as_mut() {
13240                guard.disarm();
13241            }
13242        }
13243
13244        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
13245        let publish_result: Result<()> = {
13246            let mut first_error = None;
13247            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
13248            for run in &spilled {
13249                spilled_by_table.entry(run.table_id).or_default().push(run);
13250            }
13251            let mut modified_tables = Vec::with_capacity(applies.len());
13252            // Apply every table completely before any fallible manifest write.
13253            // The visible epoch remains unchanged until all tables are coherent.
13254            for batch in applies {
13255                #[cfg(test)]
13256                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13257                let mut t = batch.handle.lock();
13258                for op in batch.ops {
13259                    match op {
13260                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
13261                        StagedOp::Delete(row_ids) => {
13262                            for row_id in row_ids {
13263                                t.apply_delete(row_id, new_epoch);
13264                            }
13265                        }
13266                        StagedOp::Truncate => t.apply_truncate(new_epoch),
13267                    }
13268                }
13269                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
13270                    for run in runs {
13271                        t.link_run(crate::manifest::RunRef {
13272                            run_id: run.run_id,
13273                            level: 0,
13274                            epoch_created: new_epoch.0,
13275                            row_count: run.row_count,
13276                        });
13277                        t.apply_run_metadata_prepared(&run.rows)?;
13278                        if truncated_tables.contains(&batch.table_id) {
13279                            // TRUNCATE + spilled puts fully describe this table at
13280                            // the commit epoch. Endorse the epoch so clean-reopen
13281                            // recovery does not replay the truncate over the
13282                            // already-linked replacement run.
13283                            t.set_flushed_epoch(new_epoch);
13284                        }
13285                    }
13286                }
13287                t.invalidate_pending_cache();
13288                drop(t);
13289                modified_tables.push(batch.handle);
13290            }
13291
13292            // Checkpoint only after every live table carries the durable state.
13293            // Continue after one checkpoint failure so runtime publication stays
13294            // all-or-nothing; WAL recovery repairs failed files on reopen.
13295            for handle in modified_tables {
13296                #[cfg(test)]
13297                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
13298                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
13299                    first_error.get_or_insert(error);
13300                }
13301            }
13302            for (name, _, pending) in &prepared_external {
13303                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
13304                    first_error.get_or_insert(error);
13305                }
13306            }
13307            if !committed_materialized_views.is_empty() {
13308                let mut next_catalog = self.catalog.read().clone();
13309                for definition in committed_materialized_views {
13310                    if let Some(existing) = next_catalog
13311                        .materialized_views
13312                        .iter_mut()
13313                        .find(|existing| existing.name == definition.name)
13314                    {
13315                        *existing = definition;
13316                    } else {
13317                        next_catalog.materialized_views.push(definition);
13318                    }
13319                }
13320                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13321                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
13322                    first_error.get_or_insert(error);
13323                }
13324            }
13325            match first_error {
13326                Some(error) => Err(error),
13327                None => Ok(()),
13328            }
13329        };
13330
13331        if has_changes {
13332            let _ = self.change_wake.send(());
13333        }
13334        self.finish_durable_publish(new_epoch, &mut _epoch_guard, &receipt, publish_result)?;
13335        // S1B-001: the commit is durable and published — record the receipt on
13336        // the formal state. (Post-publish errors above return `DurableCommit`
13337        // and deliberately leave the state `CommitCritical`.)
13338        if let Some(state) = &context.state {
13339            state.committed(receipt.clone());
13340        }
13341        // FND-006: `txn.decision.after` fires only after the decision is
13342        // durable and the Committed receipt is published. A Fail must not
13343        // undo the decision — surface as DurableCommit (post-fence).
13344        mongreldb_fault::inject("txn.decision.after").map_err(|fault| {
13345            MongrelError::DurableCommit {
13346                epoch: new_epoch.0,
13347                message: fault.to_string(),
13348            }
13349        })?;
13350        Ok((new_epoch, committed_row_ids))
13351    }
13352
13353    /// Register a read snapshot at the current visible epoch and return it with
13354    /// a guard that retains it for GC until dropped.
13355    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
13356        let e = self.epoch.visible();
13357        let g = self.snapshots.register(e);
13358        (Snapshot::at(e), g)
13359    }
13360
13361    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
13362    /// retention.
13363    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
13364        let e = self.epoch.visible();
13365        let g = self.snapshots.register_owned(e);
13366        (Snapshot::at(e), g)
13367    }
13368
13369    /// Configure a rolling history window measured in prior commit epochs.
13370    /// The first enable starts at the current epoch because earlier versions
13371    /// may already have been compacted. Increasing the window likewise cannot
13372    /// recreate history that fell outside the previous guarantee.
13373    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
13374        let _guard = self.ddl_lock.lock();
13375        let current = self.epoch.visible();
13376        let (old_epochs, old_start) = self.snapshots.history_config();
13377        let earliest_already_guaranteed = if old_epochs == 0 {
13378            current
13379        } else {
13380            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
13381        };
13382        let start = if epochs == 0 {
13383            current
13384        } else {
13385            earliest_already_guaranteed
13386        };
13387        let published = std::cell::Cell::new(false);
13388        let result = write_history_retention(&self.root, epochs, start, || {
13389            self.snapshots.configure_history(epochs, start);
13390            published.set(true);
13391        });
13392        match result {
13393            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
13394                epoch: current.0,
13395                message: format!("history-retention publication was not durable: {error}"),
13396            }),
13397            result => result,
13398        }
13399    }
13400
13401    pub fn history_retention_epochs(&self) -> u64 {
13402        self.snapshots.history_config().0
13403    }
13404
13405    pub fn earliest_retained_epoch(&self) -> Epoch {
13406        let current = self.epoch.visible();
13407        self.snapshots.history_floor(current).unwrap_or(current)
13408    }
13409
13410    /// Pin a guaranteed historical epoch for the lifetime of the returned
13411    /// guard. Rejects future epochs and epochs outside the configured window.
13412    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
13413        let current = self.epoch.visible();
13414        if epoch > current {
13415            return Err(MongrelError::InvalidArgument(format!(
13416                "epoch {} is in the future; current epoch is {}",
13417                epoch.0, current.0
13418            )));
13419        }
13420        let earliest = self.earliest_retained_epoch();
13421        if epoch < earliest {
13422            return Err(MongrelError::InvalidArgument(format!(
13423                "epoch {} is no longer retained; earliest available epoch is {}",
13424                epoch.0, earliest.0
13425            )));
13426        }
13427        let guard = self.snapshots.register_owned(epoch);
13428        Ok((Snapshot::at(epoch), guard))
13429    }
13430
13431    /// Names of all live tables.
13432    pub fn table_names(&self) -> Vec<String> {
13433        self.catalog
13434            .read()
13435            .tables
13436            .iter()
13437            .filter(|t| matches!(t.state, TableState::Live))
13438            .map(|t| t.name.clone())
13439            .collect()
13440    }
13441
13442    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
13443    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
13444    /// reaped on the next open. Call this as the last action before a
13445    /// short-lived process (CLI, one-shot script) exits. The daemon does not
13446    /// need this — its background auto-compactor handles run management.
13447    pub fn close(&self) -> Result<()> {
13448        for name in self.table_names() {
13449            if let Ok(handle) = self.table(&name) {
13450                if let Err(e) = handle.lock().close() {
13451                    eprintln!("[close] flush failed for {name}: {e}");
13452                }
13453            }
13454        }
13455        Ok(())
13456    }
13457
13458    /// Compact every mounted table: merge all sorted runs into one clean run
13459    /// so query cost stays flat (single-run fast path) instead of growing
13460    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
13461    /// rows to reclaim. Each table
13462    /// is locked individually for its own compaction; snapshot retention is
13463    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
13464    pub fn compact(&self) -> Result<(usize, usize)> {
13465        self.require(&crate::auth::Permission::Ddl)?;
13466        // S1A-004: admit the compaction pass as one core operation.
13467        let _operation = self.admit_operation()?;
13468        let mut compacted = 0;
13469        let mut skipped = 0;
13470        for name in self.table_names() {
13471            let Ok(handle) = self.table(&name) else {
13472                continue;
13473            };
13474            {
13475                let mut t = handle.lock();
13476                let before = t.run_count();
13477                if before < 2 && !t.should_compact() {
13478                    skipped += 1;
13479                    continue;
13480                }
13481                match t.compact() {
13482                    Ok(()) => {
13483                        let after = t.run_count();
13484                        compacted += 1;
13485                        eprintln!("[compact] {name}: {before} -> {after} runs");
13486                    }
13487                    Err(e) => {
13488                        eprintln!("[compact] {name}: compaction failed: {e}");
13489                        skipped += 1;
13490                    }
13491                }
13492            }
13493        }
13494        Ok((compacted, skipped))
13495    }
13496
13497    /// Compact a single table by name. Returns `Ok(true)` if it was
13498    /// compacted, `Ok(false)` if skipped (< 2 runs).
13499    pub fn compact_table(&self, name: &str) -> Result<bool> {
13500        self.require(&crate::auth::Permission::Ddl)?;
13501        let handle = self.table(name)?;
13502        let mut t = handle.lock();
13503        let before = t.run_count();
13504        if before < 2 {
13505            return Ok(false);
13506        }
13507        t.compact()?;
13508        Ok(t.run_count() < before)
13509    }
13510
13511    /// Look up a live table by name.
13512    pub fn table(&self, name: &str) -> Result<TableHandle> {
13513        self.ensure_owner_process()?;
13514        let cat = self.catalog.read();
13515        let entry = cat
13516            .live(name)
13517            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13518        let id = entry.table_id;
13519        drop(cat);
13520        self.tables
13521            .read()
13522            .get(&id)
13523            .cloned()
13524            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
13525    }
13526
13527    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
13528    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
13529    pub fn has_ttl_tables(&self) -> bool {
13530        self.tables
13531            .read()
13532            .values()
13533            .any(|table| table.lock().ttl().is_some())
13534    }
13535
13536    /// Resolve a live table id → mounted handle (used by the constraint
13537    /// validation pass and other id-qualified internal paths).
13538    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
13539        self.tables
13540            .read()
13541            .get(&id)
13542            .cloned()
13543            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
13544    }
13545
13546    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
13547    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
13548    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
13549    /// allocation; explicit values merely advance the counter (an
13550    /// order-insensitive `max` under the table lock) and must not serialize.
13551    pub(crate) fn table_auto_inc_would_allocate(
13552        &self,
13553        table_id: u64,
13554        cells: &[(u16, Value)],
13555    ) -> bool {
13556        let catalog = self.catalog.read();
13557        let Some(entry) = catalog
13558            .tables
13559            .iter()
13560            .find(|entry| entry.table_id == table_id)
13561        else {
13562            return false;
13563        };
13564        let Some(column) = entry.schema.auto_increment_column() else {
13565            return false;
13566        };
13567        matches!(
13568            cells.iter().find(|(id, _)| *id == column.id),
13569            None | Some((_, Value::Null))
13570        )
13571    }
13572
13573    /// Create a new table. The DDL is first logged to the shared WAL
13574    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
13575    /// BEFORE the in-memory catalog and table map are mutated; the catalog
13576    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
13577    /// that sees a stale catalog still recovers the table by replaying the Ddl.
13578    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
13579        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
13580            return Err(MongrelError::InvalidArgument(format!(
13581                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
13582            )));
13583        }
13584        self.create_table_with_state(name, schema, TableState::Live)
13585    }
13586
13587    /// Create a durable but non-queryable CTAS build table.
13588    #[doc(hidden)]
13589    pub fn create_building_table(
13590        &self,
13591        build_name: &str,
13592        intended_name: &str,
13593        query_id: &str,
13594        schema: Schema,
13595    ) -> Result<u64> {
13596        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13597            || intended_name.is_empty()
13598            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13599            || query_id.is_empty()
13600        {
13601            return Err(MongrelError::InvalidArgument(
13602                "invalid CTAS building-table identity".into(),
13603            ));
13604        }
13605        self.create_table_with_state(
13606            build_name,
13607            schema,
13608            TableState::Building {
13609                intended_name: intended_name.to_string(),
13610                query_id: query_id.to_string(),
13611                created_at_unix_nanos: current_unix_nanos(),
13612                replaces_table_id: None,
13613            },
13614        )
13615    }
13616
13617    /// Create a hidden schema-rebuild table while the intended target remains
13618    /// live. Publication later validates that the same target is still live.
13619    #[doc(hidden)]
13620    pub fn create_rebuilding_table(
13621        &self,
13622        build_name: &str,
13623        intended_name: &str,
13624        query_id: &str,
13625        schema: Schema,
13626    ) -> Result<u64> {
13627        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13628            || intended_name.is_empty()
13629            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13630            || query_id.is_empty()
13631        {
13632            return Err(MongrelError::InvalidArgument(
13633                "invalid rebuilding-table identity".into(),
13634            ));
13635        }
13636        let replaces_table_id = self
13637            .catalog
13638            .read()
13639            .live(intended_name)
13640            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
13641            .table_id;
13642        self.create_table_with_state(
13643            build_name,
13644            schema,
13645            TableState::Building {
13646                intended_name: intended_name.to_string(),
13647                query_id: query_id.to_string(),
13648                created_at_unix_nanos: current_unix_nanos(),
13649                replaces_table_id: Some(replaces_table_id),
13650            },
13651        )
13652    }
13653
13654    fn create_table_with_state(
13655        &self,
13656        name: &str,
13657        schema: Schema,
13658        state: TableState,
13659    ) -> Result<u64> {
13660        use crate::wal::DdlOp;
13661        use std::sync::atomic::Ordering;
13662
13663        self.require(&crate::auth::Permission::Ddl)?;
13664        if self.poisoned.load(Ordering::Relaxed) {
13665            return Err(MongrelError::Other(
13666                "database poisoned by fsync error".into(),
13667            ));
13668        }
13669        // S1A-004: admit the DDL as one core operation.
13670        let _operation = self.admit_operation()?;
13671        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13672        let _g = self.ddl_lock.lock();
13673        let _security_write = self.security_write()?;
13674        self.require(&crate::auth::Permission::Ddl)?;
13675        {
13676            let cat = self.catalog.read();
13677            match &state {
13678                TableState::Live => {
13679                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
13680                        return Err(MongrelError::InvalidArgument(format!(
13681                            "table {name:?} already exists or is being built"
13682                        )));
13683                    }
13684                }
13685                TableState::Building {
13686                    intended_name,
13687                    replaces_table_id,
13688                    ..
13689                } => {
13690                    let target_matches = match replaces_table_id {
13691                        Some(table_id) => cat
13692                            .live(intended_name)
13693                            .is_some_and(|entry| entry.table_id == *table_id),
13694                        None => cat.live(intended_name).is_none(),
13695                    };
13696                    if !target_matches || cat.building_for(intended_name).is_some() {
13697                        return Err(MongrelError::InvalidArgument(format!(
13698                            "table {intended_name:?} changed or is already being built"
13699                        )));
13700                    }
13701                    if cat.building(name).is_some() {
13702                        return Err(MongrelError::InvalidArgument(format!(
13703                            "building table {name:?} already exists"
13704                        )));
13705                    }
13706                }
13707                TableState::Dropped { .. } => {
13708                    return Err(MongrelError::InvalidArgument(
13709                        "cannot create a dropped table".into(),
13710                    ));
13711                }
13712            }
13713        }
13714
13715        // Allocate id + epoch + txn id under the commit lock so the DDL commit
13716        // is serialized with data commits (in-order publish). Live creates
13717        // draw their table id from the routed catalog command below (S1F-001);
13718        // CTAS building-table creates keep the legacy direct allocation.
13719        let commit_lock = Arc::clone(&self.commit_lock);
13720        let _c = commit_lock.lock();
13721        let live_create = matches!(state, TableState::Live);
13722        let legacy_table_id = if live_create {
13723            None
13724        } else {
13725            let mut cat = self.catalog.write();
13726            let id = cat.next_table_id;
13727            cat.next_table_id = id
13728                .checked_add(1)
13729                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
13730            Some(id)
13731        };
13732        let epoch = self.epoch.bump_assigned();
13733        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13734        let txn_id = self.alloc_txn_id()?;
13735
13736        let mut schema = schema;
13737        if let Some(table_id) = legacy_table_id {
13738            // Stamp the schema_id with the unique table_id so every table in
13739            // the database has a distinct schema_id (caller-provided values
13740            // are ignored to prevent collisions).
13741            schema.schema_id = table_id;
13742            // Defense in depth: reject an invalid schema BEFORE any durable
13743            // side-effect. `Table::create_in` re-validates, but by then the
13744            // DDL has already been appended to the shared WAL; a failing
13745            // create_in would leave a dangling entry that `recover_ddl_from_wal`
13746            // replays without re-validating, corrupting the catalog on reopen.
13747            // Validating here keeps the WAL free of schemas that can never be
13748            // opened.
13749            schema.validate_auto_increment()?;
13750            schema.validate_defaults()?;
13751            schema.validate_ai()?;
13752            for index in &schema.indexes {
13753                index.validate_options()?;
13754            }
13755            for constraint in &schema.constraints.checks {
13756                constraint.expr.validate()?;
13757            }
13758        }
13759
13760        let mut next_catalog = self.catalog.read().clone();
13761        let (table_id, schema) = match legacy_table_id {
13762            Some(table_id) => {
13763                next_catalog.tables.push(CatalogEntry {
13764                    table_id,
13765                    name: name.to_string(),
13766                    schema: schema.clone(),
13767                    state: state.clone(),
13768                    created_epoch: epoch.0,
13769                });
13770                (table_id, schema)
13771            }
13772            None => {
13773                // S1F-001: the mutation is a versioned catalog command —
13774                // schema validation (the same five validators), table-id
13775                // allocation, and schema_id stamping all resolve inside it.
13776                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
13777                    name: name.to_string(),
13778                    schema,
13779                    created_epoch: epoch.0,
13780                };
13781                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
13782                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
13783                    return Err(MongrelError::Other(
13784                        "CreateTable resolved to an unexpected catalog delta".into(),
13785                    ));
13786                };
13787                (entry.table_id, entry.schema)
13788            }
13789        };
13790        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
13791
13792        // Build the complete mounted table before its DDL can become durable.
13793        // Any failure removes the unpublished directory and abandons the epoch.
13794        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
13795        let canonical_tdir = self.root.join(&table_relative);
13796        let table_root = Arc::new(
13797            self.durable_root
13798                .create_directory_all_pinned(&table_relative)?,
13799        );
13800        let tdir = table_root.io_path()?;
13801        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
13802        let ctx = SharedCtx {
13803            root_guard: Some(table_root),
13804            epoch: Arc::clone(&self.epoch),
13805            page_cache: Arc::clone(&self.page_cache),
13806            decoded_cache: Arc::clone(&self.decoded_cache),
13807            snapshots: Arc::clone(&self.snapshots),
13808            kek: self.kek.clone(),
13809            commit_lock: Arc::clone(&self.commit_lock),
13810            shared: Some(crate::engine::SharedWalCtx {
13811                wal: Arc::clone(&self.shared_wal),
13812                group: Arc::clone(&self.group),
13813                poisoned: Arc::clone(&self.poisoned),
13814                txn_ids: Arc::clone(&self.next_txn_id),
13815                change_wake: self.change_wake.clone(),
13816                lifecycle: Arc::clone(&self.lifecycle),
13817            }),
13818            table_name: Some(name.to_string()),
13819            auth: self.table_auth_checker(),
13820            read_only: self.read_only,
13821        };
13822        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
13823
13824        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
13825        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
13826        let schema_json = DdlOp::encode_schema(&schema)?;
13827        let ddl = match &state {
13828            TableState::Live => DdlOp::CreateTable {
13829                table_id,
13830                name: name.to_string(),
13831                schema_json,
13832            },
13833            TableState::Building {
13834                intended_name,
13835                query_id,
13836                created_at_unix_nanos,
13837                replaces_table_id,
13838            } => match replaces_table_id {
13839                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
13840                    table_id,
13841                    build_name: name.to_string(),
13842                    intended_name: intended_name.clone(),
13843                    query_id: query_id.clone(),
13844                    created_at_unix_nanos: *created_at_unix_nanos,
13845                    replaces_table_id: *replaces_table_id,
13846                    schema_json,
13847                },
13848                None => DdlOp::CreateBuildingTable {
13849                    table_id,
13850                    build_name: name.to_string(),
13851                    intended_name: intended_name.clone(),
13852                    query_id: query_id.clone(),
13853                    created_at_unix_nanos: *created_at_unix_nanos,
13854                    schema_json,
13855                },
13856            },
13857            TableState::Dropped { .. } => {
13858                return Err(MongrelError::InvalidArgument(
13859                    "cannot create a table in dropped state".into(),
13860                ));
13861            }
13862        };
13863        let commit_seq = {
13864            let mut wal = self.shared_wal.lock();
13865            let append: Result<u64> = (|| {
13866                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
13867                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
13868                wal.append_commit(txn_id, epoch, &[])
13869            })();
13870            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
13871        };
13872        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
13873        pending_table_dir.disarm();
13874
13875        // Publish the mounted table and catalog in memory even if the catalog
13876        // checkpoint fails after the WAL commit.
13877        self.tables
13878            .write()
13879            .insert(table_id, TableHandle::new(table));
13880        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
13881        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
13882        Ok(table_id)
13883    }
13884
13885    /// Logically drop a table, logging the DDL through the shared WAL first.
13886    pub fn drop_table(&self, name: &str) -> Result<()> {
13887        self.drop_table_with_epoch(name).map(|_| ())
13888    }
13889
13890    /// Logically drop a table and return the exact publication epoch.
13891    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
13892        self.drop_table_with_state(name, false, None)
13893    }
13894
13895    pub fn drop_table_with_epoch_controlled<F>(
13896        &self,
13897        name: &str,
13898        mut before_commit: F,
13899    ) -> Result<Epoch>
13900    where
13901        F: FnMut() -> Result<()>,
13902    {
13903        self.drop_table_with_state(name, false, Some(&mut before_commit))
13904    }
13905
13906    /// Discard an unpublished CTAS build.
13907    #[doc(hidden)]
13908    pub fn discard_building_table(&self, name: &str) -> Result<()> {
13909        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
13910            return Err(MongrelError::InvalidArgument(
13911                "not a CTAS building table".into(),
13912            ));
13913        }
13914        self.drop_table_with_state(name, true, None).map(|_| ())
13915    }
13916
13917    fn drop_table_with_state(
13918        &self,
13919        name: &str,
13920        building: bool,
13921        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
13922    ) -> Result<Epoch> {
13923        use crate::wal::DdlOp;
13924        use std::sync::atomic::Ordering;
13925
13926        self.require(&crate::auth::Permission::Ddl)?;
13927        if self.poisoned.load(Ordering::Relaxed) {
13928            return Err(MongrelError::Other(
13929                "database poisoned by fsync error".into(),
13930            ));
13931        }
13932        // S1A-004: admit the DDL as one core operation.
13933        let _operation = self.admit_operation()?;
13934        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
13935        let _g = self.ddl_lock.lock();
13936        let _security_write = self.security_write()?;
13937        self.require(&crate::auth::Permission::Ddl)?;
13938        let table_id = {
13939            let cat = self.catalog.read();
13940            if building {
13941                cat.building(name)
13942            } else {
13943                cat.live(name)
13944            }
13945            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
13946            .table_id
13947        };
13948
13949        let commit_lock = Arc::clone(&self.commit_lock);
13950        let _c = commit_lock.lock();
13951        let epoch = self.epoch.bump_assigned();
13952        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
13953        let txn_id = self.alloc_txn_id()?;
13954        let mut next_catalog = self.catalog.read().clone();
13955        if building {
13956            // CTAS building-table discards stay on the legacy mutation: the
13957            // command model's `DropTable` resolves live tables only.
13958            let entry = next_catalog
13959                .tables
13960                .iter_mut()
13961                .find(|t| t.table_id == table_id)
13962                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13963            entry.state = TableState::Dropped { at_epoch: epoch.0 };
13964            next_catalog.triggers.retain(|trigger| {
13965                !matches!(
13966                    &trigger.trigger.target,
13967                    TriggerTarget::Table(target) if target == name
13968                )
13969            });
13970            next_catalog
13971                .materialized_views
13972                .retain(|definition| definition.name != name);
13973            next_catalog
13974                .security
13975                .rls_tables
13976                .retain(|table| table != name);
13977            next_catalog
13978                .security
13979                .policies
13980                .retain(|policy| policy.table != name);
13981            next_catalog
13982                .security
13983                .masks
13984                .retain(|mask| mask.table != name);
13985            for role in &mut next_catalog.roles {
13986                role.permissions
13987                    .retain(|permission| permission_table(permission) != Some(name));
13988            }
13989            advance_security_version(&mut next_catalog)?;
13990        } else {
13991            // S1F-001: a plain live-table drop is a versioned catalog command
13992            // (the delta mirrors the legacy mutation above, cascading
13993            // triggers, materialized views, and table-scoped security state).
13994            let command = crate::catalog_cmds::CatalogCommand::DropTable {
13995                name: name.to_string(),
13996                at_epoch: epoch.0,
13997            };
13998            self.apply_catalog_command_to(&mut next_catalog, command)?;
13999        }
14000        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14001        let commit_seq = {
14002            let mut wal = self.shared_wal.lock();
14003            if let Some(before_commit) = before_commit {
14004                before_commit()?;
14005            }
14006            let append: Result<u64> = (|| {
14007                wal.append(
14008                    txn_id,
14009                    table_id,
14010                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
14011                )?;
14012                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14013                wal.append_commit(txn_id, epoch, &[])
14014            })();
14015            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14016        };
14017        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14018
14019        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14020        self.tables.write().remove(&table_id);
14021        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14022        Ok(epoch)
14023    }
14024
14025    /// Rename a live table. `name` must exist and `new_name` must not collide
14026    /// with any live table; both checks run under `ddl_lock` so they are atomic
14027    /// with the rename and with concurrent `create_table` existence checks (no
14028    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
14029    /// side-effects. The rename is logged to the shared WAL as
14030    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
14031    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
14032    /// the in-memory object does not move — only the catalog name changes).
14033    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
14034        self.rename_table_with_epoch(name, new_name).map(|_| ())
14035    }
14036
14037    /// Rename a table and return its exact publication epoch.
14038    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
14039        self.rename_table_with_epoch_inner(name, new_name, None)
14040    }
14041
14042    pub fn rename_table_with_epoch_controlled<F>(
14043        &self,
14044        name: &str,
14045        new_name: &str,
14046        mut before_commit: F,
14047    ) -> Result<Epoch>
14048    where
14049        F: FnMut() -> Result<()>,
14050    {
14051        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
14052    }
14053
14054    fn rename_table_with_epoch_inner(
14055        &self,
14056        name: &str,
14057        new_name: &str,
14058        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14059    ) -> Result<Epoch> {
14060        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14061            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14062        {
14063            return Err(MongrelError::InvalidArgument(
14064                "the CTAS building-table namespace is reserved".into(),
14065            ));
14066        }
14067        self.rename_table_with_state(name, new_name, false, None, before_commit)
14068    }
14069
14070    /// Atomically publish a hidden CTAS build under its intended live name.
14071    #[doc(hidden)]
14072    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14073        self.publish_building_table_inner(build_name, new_name, None)
14074    }
14075
14076    #[doc(hidden)]
14077    pub fn publish_building_table_controlled<F>(
14078        &self,
14079        build_name: &str,
14080        new_name: &str,
14081        mut before_commit: F,
14082    ) -> Result<Epoch>
14083    where
14084        F: FnMut() -> Result<()>,
14085    {
14086        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
14087    }
14088
14089    fn publish_building_table_inner(
14090        &self,
14091        build_name: &str,
14092        new_name: &str,
14093        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14094    ) -> Result<Epoch> {
14095        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14096            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14097        {
14098            return Err(MongrelError::InvalidArgument(
14099                "invalid CTAS publish identity".into(),
14100            ));
14101        }
14102        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
14103    }
14104
14105    /// Atomically publish a hidden build and its materialized-view definition.
14106    #[doc(hidden)]
14107    pub fn publish_materialized_building_table(
14108        &self,
14109        build_name: &str,
14110        new_name: &str,
14111        definition: crate::catalog::MaterializedViewEntry,
14112    ) -> Result<Epoch> {
14113        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
14114    }
14115
14116    #[doc(hidden)]
14117    pub fn publish_materialized_building_table_controlled<F>(
14118        &self,
14119        build_name: &str,
14120        new_name: &str,
14121        definition: crate::catalog::MaterializedViewEntry,
14122        mut before_commit: F,
14123    ) -> Result<Epoch>
14124    where
14125        F: FnMut() -> Result<()>,
14126    {
14127        self.publish_materialized_building_table_inner(
14128            build_name,
14129            new_name,
14130            definition,
14131            Some(&mut before_commit),
14132        )
14133    }
14134
14135    fn publish_materialized_building_table_inner(
14136        &self,
14137        build_name: &str,
14138        new_name: &str,
14139        definition: crate::catalog::MaterializedViewEntry,
14140        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14141    ) -> Result<Epoch> {
14142        if definition.name != new_name || definition.query.trim().is_empty() {
14143            return Err(MongrelError::InvalidArgument(
14144                "invalid materialized-view publication".into(),
14145            ));
14146        }
14147        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
14148    }
14149
14150    /// Atomically replace a still-live table with its completed hidden rebuild.
14151    #[doc(hidden)]
14152    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14153        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
14154    }
14155
14156    #[doc(hidden)]
14157    pub fn publish_rebuilding_table_controlled<F>(
14158        &self,
14159        build_name: &str,
14160        new_name: &str,
14161        mut before_commit: F,
14162    ) -> Result<Epoch>
14163    where
14164        F: FnMut() -> Result<()>,
14165    {
14166        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
14167    }
14168
14169    /// Atomically replace a live materialized-view table and its definition.
14170    #[doc(hidden)]
14171    pub fn publish_materialized_rebuilding_table_controlled<F>(
14172        &self,
14173        build_name: &str,
14174        new_name: &str,
14175        definition: crate::catalog::MaterializedViewEntry,
14176        mut before_commit: F,
14177    ) -> Result<Epoch>
14178    where
14179        F: FnMut() -> Result<()>,
14180    {
14181        self.publish_rebuilding_table_inner(
14182            build_name,
14183            new_name,
14184            Some(definition),
14185            Some(&mut before_commit),
14186        )
14187    }
14188
14189    fn publish_rebuilding_table_inner(
14190        &self,
14191        build_name: &str,
14192        new_name: &str,
14193        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14194        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14195    ) -> Result<Epoch> {
14196        use crate::wal::DdlOp;
14197
14198        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14199            || new_name.is_empty()
14200            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14201        {
14202            return Err(MongrelError::InvalidArgument(
14203                "invalid rebuilding-table publish identity".into(),
14204            ));
14205        }
14206        if materialized_view.as_ref().is_some_and(|definition| {
14207            definition.name != new_name || definition.query.trim().is_empty()
14208        }) {
14209            return Err(MongrelError::InvalidArgument(
14210                "invalid materialized-view replacement".into(),
14211            ));
14212        }
14213        self.require(&crate::auth::Permission::Ddl)?;
14214        if self.poisoned.load(Ordering::Relaxed) {
14215            return Err(MongrelError::Other(
14216                "database poisoned by fsync error".into(),
14217            ));
14218        }
14219        // S1A-004: admit the DDL as one core operation.
14220        let _operation = self.admit_operation()?;
14221        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14222        let _ddl = self.ddl_lock.lock();
14223        let _security_write = self.security_write()?;
14224        let (table_id, replaced_table_id) = {
14225            let catalog = self.catalog.read();
14226            let build = catalog.building(build_name).ok_or_else(|| {
14227                MongrelError::NotFound(format!("building table {build_name:?} not found"))
14228            })?;
14229            let replaced_table_id = match &build.state {
14230                TableState::Building {
14231                    intended_name,
14232                    replaces_table_id: Some(replaced_table_id),
14233                    ..
14234                } if intended_name == new_name => *replaced_table_id,
14235                _ => {
14236                    return Err(MongrelError::InvalidArgument(format!(
14237                        "building table {build_name:?} is not a replacement for {new_name:?}"
14238                    )))
14239                }
14240            };
14241            if catalog
14242                .live(new_name)
14243                .is_none_or(|entry| entry.table_id != replaced_table_id)
14244            {
14245                return Err(MongrelError::Conflict(format!(
14246                    "table {new_name:?} changed while its replacement was built"
14247                )));
14248            }
14249            (build.table_id, replaced_table_id)
14250        };
14251
14252        let _commit = self.commit_lock.lock();
14253        let epoch = self.epoch.assigned().next();
14254        let txn_id = self.alloc_txn_id()?;
14255        let mut next_catalog = self.catalog.read().clone();
14256        apply_rebuilding_publish(
14257            &mut next_catalog,
14258            table_id,
14259            replaced_table_id,
14260            new_name,
14261            epoch.0,
14262        )?;
14263        if let Some(definition) = materialized_view.as_mut() {
14264            definition.last_refresh_epoch = epoch.0;
14265        }
14266        let materialized_view_json = materialized_view
14267            .as_ref()
14268            .map(DdlOp::encode_materialized_view)
14269            .transpose()?;
14270        if let Some(definition) = materialized_view {
14271            if let Some(existing) = next_catalog
14272                .materialized_views
14273                .iter_mut()
14274                .find(|existing| existing.name == definition.name)
14275            {
14276                *existing = definition;
14277            } else {
14278                next_catalog.materialized_views.push(definition);
14279            }
14280        }
14281        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14282        if let Some(before_commit) = before_commit {
14283            before_commit()?;
14284        }
14285        let assigned_epoch = self.epoch.bump_assigned();
14286        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
14287        if assigned_epoch != epoch {
14288            return Err(MongrelError::Conflict(
14289                "commit epoch changed while sequencer lock was held".into(),
14290            ));
14291        }
14292        let commit_seq = {
14293            let mut wal = self.shared_wal.lock();
14294            let append: Result<u64> = (|| {
14295                wal.append(
14296                    txn_id,
14297                    table_id,
14298                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
14299                        table_id,
14300                        replaced_table_id,
14301                        new_name: new_name.to_string(),
14302                    }),
14303                )?;
14304                if let Some(definition_json) = materialized_view_json {
14305                    wal.append(
14306                        txn_id,
14307                        table_id,
14308                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14309                            name: new_name.to_string(),
14310                            definition_json,
14311                        }),
14312                    )?;
14313                }
14314                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14315                wal.append_commit(txn_id, epoch, &[])
14316            })();
14317            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14318        };
14319        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14320
14321        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14322        self.tables.write().remove(&replaced_table_id);
14323        if let Some(table) = self.tables.read().get(&table_id) {
14324            table.lock().set_catalog_name(new_name.to_string());
14325        }
14326        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
14327        Ok(epoch)
14328    }
14329
14330    fn rename_table_with_state(
14331        &self,
14332        name: &str,
14333        new_name: &str,
14334        building: bool,
14335        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14336        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14337    ) -> Result<Epoch> {
14338        use crate::wal::DdlOp;
14339        use std::sync::atomic::Ordering;
14340
14341        self.require(&crate::auth::Permission::Ddl)?;
14342        if self.poisoned.load(Ordering::Relaxed) {
14343            return Err(MongrelError::Other(
14344                "database poisoned by fsync error".into(),
14345            ));
14346        }
14347
14348        // A no-op rename short-circuits before any locking, so it can never
14349        // trip the "target already exists" check (the source *is* that name).
14350        if name == new_name {
14351            return Ok(self.visible_epoch());
14352        }
14353        if new_name.is_empty() {
14354            return Err(MongrelError::InvalidArgument(
14355                "rename_table: new name must not be empty".into(),
14356            ));
14357        }
14358        // S1A-004: admit the DDL as one core operation.
14359        let _operation = self.admit_operation()?;
14360        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14361        let _g = self.ddl_lock.lock();
14362        let _security_write = self.security_write()?;
14363        self.require(&crate::auth::Permission::Ddl)?;
14364        let table_id = {
14365            let cat = self.catalog.read();
14366            let src = if building {
14367                cat.building(name)
14368            } else {
14369                cat.live(name)
14370            }
14371            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14372            if building
14373                && !matches!(
14374                    &src.state,
14375                    TableState::Building { intended_name, .. } if intended_name == new_name
14376                )
14377            {
14378                return Err(MongrelError::InvalidArgument(format!(
14379                    "building table {name:?} is not reserved for {new_name:?}"
14380                )));
14381            }
14382            // Target must be free. Checked under ddl_lock, which every other
14383            // DDL (create/rename/drop) also holds, so a concurrent operation
14384            // cannot claim `new_name` between this check and the catalog write.
14385            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
14386                return Err(MongrelError::InvalidArgument(format!(
14387                    "rename_table: a table named {new_name:?} already exists"
14388                )));
14389            }
14390            src.table_id
14391        };
14392
14393        let commit_lock = Arc::clone(&self.commit_lock);
14394        let _c = commit_lock.lock();
14395        let epoch = self.epoch.bump_assigned();
14396        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14397        let txn_id = self.alloc_txn_id()?;
14398        if let Some(definition) = materialized_view.as_mut() {
14399            definition.last_refresh_epoch = epoch.0;
14400        }
14401        let materialized_view_json = materialized_view
14402            .as_ref()
14403            .map(DdlOp::encode_materialized_view)
14404            .transpose()?;
14405        let mut next_catalog = self.catalog.read().clone();
14406        if building {
14407            // CTAS building-table publishes stay on the legacy mutation: the
14408            // command model's `RenameTable` resolves live tables only.
14409            let entry = next_catalog
14410                .tables
14411                .iter_mut()
14412                .find(|t| t.table_id == table_id)
14413                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14414            entry.name = new_name.to_string();
14415            entry.state = TableState::Live;
14416            for trigger in &mut next_catalog.triggers {
14417                if matches!(
14418                    &trigger.trigger.target,
14419                    TriggerTarget::Table(target) if target == name
14420                ) {
14421                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
14422                }
14423            }
14424            if let Some(definition) = next_catalog
14425                .materialized_views
14426                .iter_mut()
14427                .find(|definition| definition.name == name)
14428            {
14429                definition.name = new_name.to_string();
14430            }
14431            if let Some(definition) = materialized_view.take() {
14432                next_catalog.materialized_views.push(definition);
14433            }
14434            for table in &mut next_catalog.security.rls_tables {
14435                if table == name {
14436                    *table = new_name.to_string();
14437                }
14438            }
14439            for policy in &mut next_catalog.security.policies {
14440                if policy.table == name {
14441                    policy.table = new_name.to_string();
14442                }
14443            }
14444            for mask in &mut next_catalog.security.masks {
14445                if mask.table == name {
14446                    mask.table = new_name.to_string();
14447                }
14448            }
14449            for role in &mut next_catalog.roles {
14450                for permission in &mut role.permissions {
14451                    rename_permission_table(permission, name, new_name);
14452                }
14453            }
14454            advance_security_version(&mut next_catalog)?;
14455        } else {
14456            // S1F-001: a plain live-table rename is a versioned catalog
14457            // command (the delta mirrors the legacy mutation above, including
14458            // trigger retargets and table-scoped security state).
14459            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
14460                name: name.to_string(),
14461                new_name: new_name.to_string(),
14462                at_epoch: epoch.0,
14463            };
14464            self.apply_catalog_command_to(&mut next_catalog, command)?;
14465        }
14466        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14467        let ddl = if building {
14468            DdlOp::PublishBuildingTable {
14469                table_id,
14470                new_name: new_name.to_string(),
14471            }
14472        } else {
14473            DdlOp::RenameTable {
14474                table_id,
14475                new_name: new_name.to_string(),
14476            }
14477        };
14478        let commit_seq = {
14479            let mut wal = self.shared_wal.lock();
14480            if let Some(before_commit) = before_commit {
14481                before_commit()?;
14482            }
14483            let append: Result<u64> = (|| {
14484                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14485                if let Some(definition_json) = materialized_view_json {
14486                    wal.append(
14487                        txn_id,
14488                        table_id,
14489                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14490                            name: new_name.to_string(),
14491                            definition_json,
14492                        }),
14493                    )?;
14494                }
14495                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14496                wal.append_commit(txn_id, epoch, &[])
14497            })();
14498            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14499        };
14500        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14501
14502        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14503        // The in-memory table object is keyed by table_id, not name, so it does
14504        // not move and live TableHandles remain valid.
14505        if let Some(table) = self.tables.read().get(&table_id) {
14506            table.lock().set_catalog_name(new_name.to_string());
14507        }
14508        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14509        Ok(epoch)
14510    }
14511
14512    /// Add a column through the database catalog and shared WAL.
14513    ///
14514    /// This is the catalog-aware counterpart to [`Table::add_column`]. The
14515    /// mounted table schema, catalog image, and recovery record advance as one
14516    /// durable DDL commit.
14517    pub fn add_column(
14518        &self,
14519        table_name: &str,
14520        name: &str,
14521        ty: TypeId,
14522        flags: ColumnFlags,
14523        default_value: Option<crate::schema::DefaultExpr>,
14524    ) -> Result<ColumnDef> {
14525        self.add_column_with_id(table_name, name, ty, flags, default_value, None)
14526    }
14527
14528    /// Add a catalog-aware column with an optional caller-assigned stable id.
14529    pub fn add_column_with_id(
14530        &self,
14531        table_name: &str,
14532        name: &str,
14533        ty: TypeId,
14534        flags: ColumnFlags,
14535        default_value: Option<crate::schema::DefaultExpr>,
14536        requested_id: Option<u16>,
14537    ) -> Result<ColumnDef> {
14538        use crate::wal::DdlOp;
14539        use std::sync::atomic::Ordering;
14540
14541        self.require(&crate::auth::Permission::Ddl)?;
14542        if self.poisoned.load(Ordering::Relaxed) {
14543            return Err(MongrelError::Other(
14544                "database poisoned by fsync error".into(),
14545            ));
14546        }
14547        let _operation = self.admit_operation()?;
14548        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14549        let _ddl = self.ddl_lock.lock();
14550        let _security_write = self.security_write()?;
14551        self.require(&crate::auth::Permission::Ddl)?;
14552        let table_id = {
14553            let catalog = self.catalog.read();
14554            catalog
14555                .live(table_name)
14556                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
14557                .table_id
14558        };
14559        let handle =
14560            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
14561                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
14562            })?;
14563        let durable_epoch = std::cell::Cell::new(None);
14564        let result: Result<ColumnDef> = (|| {
14565            let mut table = handle.lock();
14566            let (column, prepared_schema) =
14567                table.prepare_add_column(name, ty, flags, default_value, requested_id)?;
14568            let command = crate::catalog_cmds::CatalogCommand::AddColumn {
14569                table: table_name.to_string(),
14570                column: column.clone(),
14571            };
14572            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
14573
14574            let commit_lock = Arc::clone(&self.commit_lock);
14575            let _commit = commit_lock.lock();
14576            let epoch = self.epoch.bump_assigned();
14577            let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14578            let txn_id = self.alloc_txn_id()?;
14579            let column_json = DdlOp::encode_column(&column)?;
14580            let mut next_catalog = self.catalog.read().clone();
14581            let catalog_entry_index = next_catalog
14582                .tables
14583                .iter()
14584                .position(|entry| entry.table_id == table_id)
14585                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
14586            self.apply_catalog_command_to(&mut next_catalog, command)?;
14587            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
14588            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14589            let commit_seq = {
14590                let mut wal = self.shared_wal.lock();
14591                let append: Result<u64> = (|| {
14592                    wal.append(
14593                        txn_id,
14594                        table_id,
14595                        crate::wal::Op::Ddl(DdlOp::AlterTable {
14596                            table_id,
14597                            column_json,
14598                        }),
14599                    )?;
14600                    append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14601                    wal.append_commit(txn_id, epoch, &[])
14602                })();
14603                append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14604            };
14605            let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14606            durable_epoch.set(Some(epoch));
14607
14608            table.apply_altered_schema_prepared(prepared_schema);
14609            let schema = table.schema().clone();
14610            let table_checkpoint = table.checkpoint_altered_schema();
14611            drop(table);
14612            next_catalog.tables[catalog_entry_index].schema = schema;
14613            let catalog_result =
14614                catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
14615            *self.catalog.write() = next_catalog;
14616            self.publish_committed(&receipt, epoch)?;
14617            epoch_guard.disarm();
14618            if let Err(error) = table_checkpoint.and(catalog_result) {
14619                self.poisoned.store(true, Ordering::Relaxed);
14620                self.lifecycle.poison();
14621                return Err(MongrelError::DurableCommit {
14622                    epoch: epoch.0,
14623                    message: error.to_string(),
14624                });
14625            }
14626            Ok(column)
14627        })();
14628        result.map_err(|error| match (durable_epoch.get(), error) {
14629            (_, error @ MongrelError::DurableCommit { .. }) => error,
14630            (Some(epoch), error) => MongrelError::DurableCommit {
14631                epoch: epoch.0,
14632                message: error.to_string(),
14633            },
14634            (None, error) => error,
14635        })
14636    }
14637
14638    pub fn alter_column(
14639        &self,
14640        table_name: &str,
14641        column_name: &str,
14642        change: AlterColumn,
14643    ) -> Result<ColumnDef> {
14644        self.alter_column_with_epoch(table_name, column_name, change)
14645            .map(|(column, _)| column)
14646    }
14647
14648    pub fn alter_column_with_epoch(
14649        &self,
14650        table_name: &str,
14651        column_name: &str,
14652        change: AlterColumn,
14653    ) -> Result<(ColumnDef, Option<Epoch>)> {
14654        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
14655    }
14656
14657    /// Cooperatively prepare an ALTER and fence each durable commit separately.
14658    /// `after_commit(Some(epoch))` follows an exact durable outcome;
14659    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
14660    /// for every successful `before_commit` callback.
14661    pub fn alter_column_with_epoch_controlled<B, A>(
14662        &self,
14663        table_name: &str,
14664        column_name: &str,
14665        change: AlterColumn,
14666        control: &crate::ExecutionControl,
14667        mut before_commit: B,
14668        mut after_commit: A,
14669    ) -> Result<(ColumnDef, Option<Epoch>)>
14670    where
14671        B: FnMut() -> Result<()>,
14672        A: FnMut(Option<Epoch>) -> Result<()>,
14673    {
14674        self.alter_column_with_epoch_inner(
14675            table_name,
14676            column_name,
14677            change,
14678            Some(control),
14679            Some(&mut before_commit),
14680            Some(&mut after_commit),
14681        )
14682    }
14683
14684    #[allow(clippy::too_many_arguments)]
14685    fn alter_column_with_epoch_inner(
14686        &self,
14687        table_name: &str,
14688        column_name: &str,
14689        change: AlterColumn,
14690        control: Option<&crate::ExecutionControl>,
14691        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14692        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
14693    ) -> Result<(ColumnDef, Option<Epoch>)> {
14694        use crate::wal::DdlOp;
14695        use std::sync::atomic::Ordering;
14696
14697        self.require(&crate::auth::Permission::Ddl)?;
14698        commit_prepare_checkpoint(control, 0)?;
14699        if self.poisoned.load(Ordering::Relaxed) {
14700            return Err(MongrelError::Other(
14701                "database poisoned by fsync error".into(),
14702            ));
14703        }
14704        // S1A-004: admit the DDL as one core operation.
14705        let _operation = self.admit_operation()?;
14706        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14707        let _g = self.ddl_lock.lock();
14708        let table_id = {
14709            let cat = self.catalog.read();
14710            cat.live(table_name)
14711                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
14712                .table_id
14713        };
14714        let handle =
14715            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
14716                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
14717            })?;
14718
14719        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
14720        // declared default, backfill existing NULL/absent cells as one durable
14721        // transaction before logging the metadata change. A crash between the
14722        // two commits leaves a harmless nullable-but-filled column; retry is
14723        // idempotent because only remaining NULLs are touched.
14724        let backfill = {
14725            let table = handle.lock();
14726            let old = table
14727                .schema()
14728                .column(column_name)
14729                .cloned()
14730                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
14731            let next_flags = change.flags.unwrap_or(old.flags);
14732            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
14733                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
14734                && old.default_value.is_some()
14735            {
14736                let snapshot = Snapshot::at(self.epoch.visible());
14737                let mut updates = Vec::new();
14738                let rows = match control {
14739                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
14740                    None => table.visible_rows(snapshot)?,
14741                };
14742                for (row_index, row) in rows.into_iter().enumerate() {
14743                    commit_prepare_checkpoint(control, row_index)?;
14744                    if row
14745                        .columns
14746                        .get(&old.id)
14747                        .is_some_and(|value| !matches!(value, Value::Null))
14748                    {
14749                        continue;
14750                    }
14751                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
14752                    table.apply_defaults(&mut cells)?;
14753                    updates.push((
14754                        table_id,
14755                        crate::txn::Staged::Update {
14756                            row_id: row.row_id,
14757                            new_row: cells,
14758                            changed_columns: vec![old.id],
14759                        },
14760                    ));
14761                }
14762                updates
14763            } else {
14764                Vec::new()
14765            }
14766        };
14767        let durable_epoch = std::cell::Cell::new(None);
14768        let backfill_epoch = if backfill.is_empty() {
14769            None
14770        } else {
14771            let (principal, catalog_bound) = self.transaction_principal_snapshot();
14772            let txn_id = self.alloc_txn_id()?;
14773            let mut entered_fence = false;
14774            let commit_result = match (control, before_commit.as_deref_mut()) {
14775                (Some(control), Some(before_commit)) => self
14776                    .commit_transaction_with_external_states_controlled(
14777                        txn_id,
14778                        self.epoch.visible(),
14779                        backfill,
14780                        Vec::new(),
14781                        Vec::new(),
14782                        principal.clone(),
14783                        catalog_bound,
14784                        None,
14785                        crate::txn::TxnCommitContext::internal(),
14786                        control,
14787                        &mut || {
14788                            before_commit()?;
14789                            entered_fence = true;
14790                            Ok(())
14791                        },
14792                    )
14793                    .map(|(epoch, _)| epoch),
14794                _ => self
14795                    .commit_transaction_with_external_states(
14796                        txn_id,
14797                        self.epoch.visible(),
14798                        backfill,
14799                        Vec::new(),
14800                        Vec::new(),
14801                        principal,
14802                        catalog_bound,
14803                        None,
14804                        crate::txn::TxnCommitContext::internal(),
14805                    )
14806                    .map(|(epoch, _)| epoch),
14807            };
14808            let commit_result = if entered_fence {
14809                finish_controlled_commit_attempt(commit_result, &mut after_commit)
14810            } else {
14811                commit_result
14812            };
14813            match &commit_result {
14814                Ok(epoch) => durable_epoch.set(Some(*epoch)),
14815                Err(MongrelError::DurableCommit { epoch, .. }) => {
14816                    durable_epoch.set(Some(Epoch(*epoch)));
14817                }
14818                Err(_) => {}
14819            }
14820            Some(commit_result?)
14821        };
14822        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
14823            let _security_write = self.security_write()?;
14824            self.require(&crate::auth::Permission::Ddl)?;
14825            if self
14826                .catalog
14827                .read()
14828                .live(table_name)
14829                .is_none_or(|entry| entry.table_id != table_id)
14830            {
14831                return Err(MongrelError::Conflict(format!(
14832                    "table {table_name:?} changed during ALTER"
14833                )));
14834            }
14835            let mut table = handle.lock();
14836            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
14837            let renamed_column = (column.name != column_name).then(|| column.name.clone());
14838            let Some(prepared_schema) = prepared_schema else {
14839                return Ok((column, backfill_epoch));
14840            };
14841            // S1F-001: the schema mutation is a versioned catalog command. It
14842            // validates pure against the current catalog before an epoch is
14843            // consumed; the engine's post-apply schema (schema_id bump
14844            // included) is the resolved delta the wrapper publishes.
14845            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
14846                table: table_name.to_string(),
14847                column: column.clone(),
14848            };
14849            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
14850
14851            let commit_lock = Arc::clone(&self.commit_lock);
14852            let _c = commit_lock.lock();
14853            let epoch = self.epoch.bump_assigned();
14854            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14855            let txn_id = self.alloc_txn_id()?;
14856            let column_json = DdlOp::encode_column(&column)?;
14857            let mut next_catalog = self.catalog.read().clone();
14858            let catalog_entry_index = next_catalog
14859                .tables
14860                .iter()
14861                .position(|entry| entry.table_id == table_id)
14862                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
14863            if let Some(new_column_name) = &renamed_column {
14864                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
14865                    commit_prepare_checkpoint(control, trigger_index)?;
14866                    if matches!(
14867                        &trigger.trigger.target,
14868                        TriggerTarget::Table(target) if target == table_name
14869                    ) {
14870                        trigger.trigger = trigger.trigger.renamed_update_column(
14871                            column_name,
14872                            new_column_name.clone(),
14873                            epoch.0,
14874                        )?;
14875                    }
14876                }
14877                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
14878                    commit_prepare_checkpoint(control, role_index)?;
14879                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
14880                        commit_prepare_checkpoint(control, permission_index)?;
14881                        rename_permission_column(
14882                            permission,
14883                            table_name,
14884                            column_name,
14885                            new_column_name,
14886                        );
14887                    }
14888                }
14889                advance_security_version(&mut next_catalog)?;
14890            }
14891            // Record the versioned command (validating again against the
14892            // candidate), then install the engine-resolved schema image:
14893            // identical to the command's delta when the mounted table and the
14894            // catalog are in sync, and byte-for-byte what the legacy inline
14895            // mutation published either way.
14896            self.apply_catalog_command_to(&mut next_catalog, command)?;
14897            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
14898            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14899            commit_prepare_checkpoint(control, 0)?;
14900            let mut entered_fence = false;
14901            if let Some(before_commit) = before_commit.as_deref_mut() {
14902                before_commit()?;
14903                entered_fence = true;
14904            }
14905            let commit_result: Result<Epoch> = (|| {
14906                let commit_seq = {
14907                    let mut wal = self.shared_wal.lock();
14908                    let append: Result<u64> = (|| {
14909                        wal.append(
14910                            txn_id,
14911                            table_id,
14912                            crate::wal::Op::Ddl(DdlOp::AlterTable {
14913                                table_id,
14914                                column_json,
14915                            }),
14916                        )?;
14917                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14918                        wal.append_commit(txn_id, epoch, &[])
14919                    })();
14920                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14921                };
14922                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14923                durable_epoch.set(Some(epoch));
14924
14925                table.apply_altered_schema_prepared(prepared_schema);
14926                let schema = table.schema().clone();
14927                let table_checkpoint = table.checkpoint_altered_schema();
14928                drop(table);
14929                next_catalog.tables[catalog_entry_index].schema = schema;
14930                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14931                let catalog_result =
14932                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
14933                let security_version = next_catalog.security_version;
14934                *self.catalog.write() = next_catalog;
14935                if renamed_column.is_some() {
14936                    self.security_coordinator
14937                        .version
14938                        .store(security_version, Ordering::Release);
14939                }
14940                self.publish_committed(&receipt, epoch)?;
14941                _epoch_guard.disarm();
14942                if let Err(error) = table_checkpoint.and(catalog_result) {
14943                    self.poisoned.store(true, Ordering::Relaxed);
14944                    self.lifecycle.poison();
14945                    return Err(MongrelError::DurableCommit {
14946                        epoch: epoch.0,
14947                        message: error.to_string(),
14948                    });
14949                }
14950                Ok(epoch)
14951            })();
14952            let commit_result = if entered_fence {
14953                finish_controlled_commit_attempt(commit_result, &mut after_commit)
14954            } else {
14955                commit_result
14956            };
14957            let epoch = commit_result?;
14958            Ok((column, Some(epoch)))
14959        })();
14960        result.map_err(|error| match (durable_epoch.get(), error) {
14961            (_, error @ MongrelError::DurableCommit { .. }) => error,
14962            (Some(epoch), error) => MongrelError::DurableCommit {
14963                epoch: epoch.0,
14964                message: error.to_string(),
14965            },
14966            (None, error) => error,
14967        })
14968    }
14969
14970    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
14971    /// replication. Duration is in nanoseconds.
14972    pub fn set_table_ttl(
14973        &self,
14974        table_name: &str,
14975        column_name: &str,
14976        duration_nanos: u64,
14977    ) -> Result<crate::manifest::TtlPolicy> {
14978        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
14979        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
14980    }
14981
14982    /// Set TTL metadata on a hidden build before it is published.
14983    #[doc(hidden)]
14984    pub fn set_building_table_ttl(
14985        &self,
14986        table_name: &str,
14987        column_name: &str,
14988        duration_nanos: u64,
14989    ) -> Result<crate::manifest::TtlPolicy> {
14990        let policy = self.replace_table_ttl_with_state(
14991            table_name,
14992            Some((column_name, duration_nanos)),
14993            true,
14994        )?;
14995        policy
14996            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
14997    }
14998
14999    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
15000        self.replace_table_ttl(table_name, None)?;
15001        Ok(())
15002    }
15003
15004    fn replace_table_ttl(
15005        &self,
15006        table_name: &str,
15007        requested: Option<(&str, u64)>,
15008    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15009        self.replace_table_ttl_with_state(table_name, requested, false)
15010    }
15011
15012    fn replace_table_ttl_with_state(
15013        &self,
15014        table_name: &str,
15015        requested: Option<(&str, u64)>,
15016        building: bool,
15017    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15018        use crate::wal::DdlOp;
15019        use std::sync::atomic::Ordering;
15020
15021        self.require(&crate::auth::Permission::Ddl)?;
15022        if self.poisoned.load(Ordering::Relaxed) {
15023            return Err(MongrelError::Other(
15024                "database poisoned by fsync error".into(),
15025            ));
15026        }
15027
15028        let _g = self.ddl_lock.lock();
15029        let _security_write = self.security_write()?;
15030        self.require(&crate::auth::Permission::Ddl)?;
15031        let table_id = {
15032            let cat = self.catalog.read();
15033            if building {
15034                cat.building(table_name)
15035            } else {
15036                cat.live(table_name)
15037            }
15038            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15039            .table_id
15040        };
15041        let handle =
15042            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15043                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15044            })?;
15045        let mut table = handle.lock();
15046        let policy = match requested {
15047            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
15048            None => None,
15049        };
15050        if table.ttl() == policy {
15051            return Ok(policy);
15052        }
15053
15054        let commit_lock = Arc::clone(&self.commit_lock);
15055        let _c = commit_lock.lock();
15056        let epoch = self.epoch.bump_assigned();
15057        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15058        let txn_id = self.alloc_txn_id()?;
15059        let policy_json = DdlOp::encode_ttl(policy)?;
15060        let mut next_catalog = self.catalog.read().clone();
15061        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15062        let commit_seq = {
15063            let mut wal = self.shared_wal.lock();
15064            let append: Result<u64> = (|| {
15065                wal.append(
15066                    txn_id,
15067                    table_id,
15068                    crate::wal::Op::Ddl(DdlOp::SetTtl {
15069                        table_id,
15070                        policy_json,
15071                    }),
15072                )?;
15073                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15074                wal.append_commit(txn_id, epoch, &[])
15075            })();
15076            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15077        };
15078        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15079
15080        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
15081        drop(table);
15082        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
15083            publish_error.get_or_insert(error);
15084        }
15085        self.finish_durable_publish(
15086            epoch,
15087            &mut _epoch_guard,
15088            &receipt,
15089            publish_error.map_or(Ok(()), Err),
15090        )?;
15091        Ok(policy)
15092    }
15093
15094    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
15095    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
15096    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
15097    ///
15098    /// Returns the number of items reclaimed.
15099    pub fn gc(&self) -> Result<usize> {
15100        let control = crate::ExecutionControl::new(None);
15101        self.gc_controlled(&control, || true)
15102    }
15103
15104    /// Discover reclaimable state cooperatively, then cross one publication
15105    /// boundary immediately before the first irreversible deletion.
15106    #[doc(hidden)]
15107    pub fn gc_controlled<F>(
15108        &self,
15109        control: &crate::ExecutionControl,
15110        before_publish: F,
15111    ) -> Result<usize>
15112    where
15113        F: FnOnce() -> bool,
15114    {
15115        self.gc_controlled_with_receipt(control, before_publish)
15116            .map(|(reclaimed, _)| reclaimed)
15117    }
15118
15119    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
15120    /// return that snapshot if an irreversible deletion was attempted.
15121    #[doc(hidden)]
15122    pub fn gc_controlled_with_receipt<F>(
15123        &self,
15124        control: &crate::ExecutionControl,
15125        before_publish: F,
15126    ) -> Result<(usize, Option<MaintenanceReceipt>)>
15127    where
15128        F: FnOnce() -> bool,
15129    {
15130        enum Candidate {
15131            Directory(PathBuf),
15132            File(PathBuf),
15133        }
15134
15135        self.require(&crate::auth::Permission::Ddl)?;
15136        // S1A-004: admit the maintenance pass as one core operation.
15137        let _operation = self.admit_operation()?;
15138        let _ddl = self.ddl_lock.lock();
15139        self.require(&crate::auth::Permission::Ddl)?;
15140        control.checkpoint()?;
15141        let maintenance_epoch = self.epoch.visible();
15142        let min_active = self.snapshots.min_active(maintenance_epoch).0;
15143        let mut candidates = Vec::new();
15144
15145        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
15146        let cat = self.catalog.read();
15147        for (entry_index, entry) in cat.tables.iter().enumerate() {
15148            if entry_index % 256 == 0 {
15149                control.checkpoint()?;
15150            }
15151            if let TableState::Dropped { at_epoch } = entry.state {
15152                if at_epoch <= min_active {
15153                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
15154                    if tdir.exists() {
15155                        candidates.push(Candidate::Directory(tdir));
15156                    }
15157                }
15158            }
15159        }
15160        drop(cat);
15161
15162        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
15163        // in-flight spill's dir (deleting it would lose the pending run and fail
15164        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
15165        // skip any id still registered in `active_spills`.
15166        let cat = self.catalog.read();
15167        for (entry_index, entry) in cat.tables.iter().enumerate() {
15168            if entry_index % 256 == 0 {
15169                control.checkpoint()?;
15170            }
15171            if !matches!(entry.state, TableState::Live) {
15172                continue;
15173            }
15174            let txn_dir = self
15175                .root
15176                .join(TABLES_DIR)
15177                .join(entry.table_id.to_string())
15178                .join("_txn");
15179            if !txn_dir.exists() {
15180                continue;
15181            }
15182            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
15183                if sub_index % 256 == 0 {
15184                    control.checkpoint()?;
15185                }
15186                let sub = sub?;
15187                let name = sub.file_name();
15188                let Some(name) = name.to_str() else { continue };
15189                // A non-numeric entry can't belong to a live txn — sweep it.
15190                let is_active = name
15191                    .parse::<u64>()
15192                    .map(|id| self.active_spills.is_active(id))
15193                    .unwrap_or(false);
15194                if is_active {
15195                    continue;
15196                }
15197                candidates.push(Candidate::Directory(sub.path()));
15198            }
15199        }
15200        drop(cat);
15201
15202        let external_names = {
15203            let cat = self.catalog.read();
15204            cat.external_tables
15205                .iter()
15206                .map(|entry| entry.name.clone())
15207                .collect::<std::collections::HashSet<_>>()
15208        };
15209        let vtab_dir = self.root.join(VTAB_DIR);
15210        if vtab_dir.exists() {
15211            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
15212                if entry_index % 256 == 0 {
15213                    control.checkpoint()?;
15214                }
15215                let entry = entry?;
15216                let name = entry.file_name();
15217                let Some(name) = name.to_str() else { continue };
15218                if external_names.contains(name) {
15219                    continue;
15220                }
15221                let path = entry.path();
15222                if path.is_dir() {
15223                    candidates.push(Candidate::Directory(path));
15224                } else {
15225                    candidates.push(Candidate::File(path));
15226                }
15227            }
15228        }
15229
15230        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
15231        // can still need (spec §6.4). Each table deletes its own retired files
15232        // gated on `min_active` and persists its manifest.
15233        let tables = self
15234            .tables
15235            .read()
15236            .iter()
15237            .map(|(table_id, handle)| (*table_id, handle.clone()))
15238            .collect::<Vec<_>>();
15239        let mut retiring = Vec::new();
15240        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
15241            if table_index % 256 == 0 {
15242                control.checkpoint()?;
15243            }
15244            let backup_pinned: HashSet<u128> = self
15245                .backup_pins
15246                .lock()
15247                .keys()
15248                .filter_map(|(pinned_table, run_id)| {
15249                    (*pinned_table == *table_id).then_some(*run_id)
15250                })
15251                .collect();
15252            if handle
15253                .lock()
15254                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
15255            {
15256                retiring.push((handle.clone(), backup_pinned));
15257            }
15258        }
15259
15260        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
15261        // segment on every reopen without truncating the prior ones, so rotated
15262        // segments accumulate. Once every live table's committed data is durable
15263        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
15264        // (non-active) segments are redundant for recovery and safe to delete —
15265        // an in-flight txn only ever appends to the active segment, which is
15266        // never deleted.
15267        let all_durable = self.active_spills.is_idle()
15268            && tables.iter().all(|(_, handle)| {
15269                let g = handle.lock();
15270                g.memtable_len() == 0 && g.mutable_run_len() == 0
15271            });
15272        let retain = self
15273            .replication_wal_retention_segments
15274            .load(std::sync::atomic::Ordering::Relaxed);
15275        let reap_wal = all_durable
15276            && self
15277                .shared_wal
15278                .lock()
15279                .has_gc_segments_retain_recent(retain)?;
15280
15281        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
15282            return Ok((0, None));
15283        }
15284        control.checkpoint()?;
15285        if !before_publish() {
15286            return Err(MongrelError::Cancelled);
15287        }
15288
15289        let mut reclaimed = 0;
15290        for candidate in candidates {
15291            match candidate {
15292                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
15293                Candidate::File(path) => std::fs::remove_file(path)?,
15294            }
15295            reclaimed += 1;
15296        }
15297        for (handle, backup_pinned) in retiring {
15298            reclaimed += handle
15299                .lock()
15300                .reap_retiring(Epoch(min_active), &backup_pinned)?;
15301        }
15302        if reap_wal {
15303            reclaimed += self
15304                .shared_wal
15305                .lock()
15306                .gc_segments_retain_recent(u64::MAX, retain)?;
15307        }
15308
15309        Ok((
15310            reclaimed,
15311            Some(MaintenanceReceipt {
15312                epoch: maintenance_epoch,
15313            }),
15314        ))
15315    }
15316
15317    /// Produce a deterministic-stable byte image of the database directory.
15318    ///
15319    /// After `checkpoint()`:
15320    ///   - All pending writes are flushed to sorted runs (no memtable data).
15321    ///   - Each table is compacted to a single sorted run (no run fragmentation).
15322    ///   - All non-active WAL segments are deleted (data is durable in runs).
15323    ///   - The active WAL segment is rotated to a fresh empty segment.
15324    ///   - Dropped-table directories are removed.
15325    ///   - All manifests + catalog are persisted.
15326    ///
15327    /// The resulting directory is byte-stable: `git add` captures a snapshot
15328    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
15329    /// no unbounded segment growth, no mutable-run spill files.
15330    ///
15331    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
15332    /// It does NOT clear the exclusive lock — the caller still owns the
15333    /// database handle.
15334    pub fn checkpoint(&self) -> Result<()> {
15335        self.checkpoint_controlled(|| Ok(()))
15336    }
15337
15338    /// Strict checkpoint with a deterministic test hook after every table is
15339    /// flushed/compacted but before WAL replacement.
15340    #[doc(hidden)]
15341    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
15342    where
15343        F: FnOnce() -> Result<()>,
15344    {
15345        self.require(&crate::auth::Permission::Ddl)?;
15346        // S1A-004: admit the checkpoint as one core operation.
15347        let _operation = self.admit_operation()?;
15348        // Block cross-table commits and DDL for the full operation. Locking all
15349        // mounted handles also excludes direct `Table` commits, which do not
15350        // enter the database replication barrier.
15351        let _replication = self.replication_barrier.write();
15352        let _ddl = self.ddl_lock.lock();
15353        let _security = self.security_coordinator.gate.read();
15354        self.require(&crate::auth::Permission::Ddl)?;
15355
15356        let mut handles = self
15357            .tables
15358            .read()
15359            .iter()
15360            .map(|(table_id, handle)| (*table_id, handle.clone()))
15361            .collect::<Vec<_>>();
15362        handles.sort_by_key(|(table_id, _)| *table_id);
15363        let mut tables = handles
15364            .iter()
15365            .map(|(table_id, handle)| (*table_id, handle.lock()))
15366            .collect::<Vec<_>>();
15367
15368        // Strict flush. Any error leaves the old WAL recovery source intact.
15369        for (_, table) in &mut tables {
15370            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
15371            {
15372                table.force_flush()?;
15373            }
15374        }
15375
15376        // Strict compaction. Checkpoint never reports a stable image after a
15377        // skipped failure.
15378        for (_, table) in &mut tables {
15379            if table.run_count() >= 2 || table.should_compact() {
15380                table.compact()?;
15381            }
15382        }
15383
15384        before_wal_reset()?;
15385
15386        // Reap table-local retired runs while every table remains quiesced.
15387        let maintenance_epoch = self.epoch.visible();
15388        let min_active = self.snapshots.min_active(maintenance_epoch);
15389        for (table_id, table) in &mut tables {
15390            let backup_pinned: HashSet<u128> = self
15391                .backup_pins
15392                .lock()
15393                .keys()
15394                .filter_map(|(pinned_table, run_id)| {
15395                    (*pinned_table == *table_id).then_some(*run_id)
15396                })
15397                .collect();
15398            table.reap_retiring(min_active, &backup_pinned)?;
15399        }
15400
15401        // Publish a fresh synced active WAL, then durably reap every older
15402        // segment. This point is reached only after every strict flush succeeds.
15403        self.shared_wal.lock().reset_after_checkpoint()?;
15404
15405        // Remove catalog-unreachable directories and stale transaction state.
15406        let catalog_snapshot = self.catalog.read().clone();
15407        for entry in &catalog_snapshot.tables {
15408            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
15409                crate::durable_file::remove_directory_all(
15410                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
15411                )?;
15412            }
15413            if !matches!(entry.state, TableState::Live) {
15414                continue;
15415            }
15416            let transaction_dir = self
15417                .root
15418                .join(TABLES_DIR)
15419                .join(entry.table_id.to_string())
15420                .join("_txn");
15421            if transaction_dir.is_dir() {
15422                for child in std::fs::read_dir(&transaction_dir)? {
15423                    let child = child?;
15424                    let active = child
15425                        .file_name()
15426                        .to_str()
15427                        .and_then(|name| name.parse::<u64>().ok())
15428                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
15429                    if !active {
15430                        crate::durable_file::remove_directory_all(&child.path())?;
15431                    }
15432                }
15433            }
15434        }
15435        let external_names = catalog_snapshot
15436            .external_tables
15437            .iter()
15438            .map(|entry| entry.name.as_str())
15439            .collect::<HashSet<_>>();
15440        let external_root = self.root.join(VTAB_DIR);
15441        if external_root.is_dir() {
15442            for entry in std::fs::read_dir(&external_root)? {
15443                let entry = entry?;
15444                let name = entry.file_name();
15445                if name
15446                    .to_str()
15447                    .is_some_and(|name| external_names.contains(name))
15448                {
15449                    continue;
15450                }
15451                if entry.file_type()?.is_dir() {
15452                    crate::durable_file::remove_directory_all(&entry.path())?;
15453                } else {
15454                    std::fs::remove_file(entry.path())?;
15455                    crate::durable_file::sync_directory(&external_root)?;
15456                }
15457            }
15458        }
15459
15460        // Final authoritative metadata checkpoint while all writers remain
15461        // excluded.
15462        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
15463        let visible = self.epoch.visible();
15464        for (_, table) in &tables {
15465            table.persist_manifest(visible)?;
15466        }
15467
15468        Ok(())
15469    }
15470    fn alloc_txn_id(&self) -> Result<u64> {
15471        self.ensure_owner_process()?;
15472        crate::txn::allocate_txn_id(&self.next_txn_id)
15473    }
15474
15475    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
15476    /// (or other multi-statement lock holds). Released via
15477    /// [`Self::release_txn_locks`].
15478    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
15479        self.alloc_txn_id()
15480    }
15481
15482    /// Set the per-table spill threshold (bytes). When a transaction's staged
15483    /// bytes for a single table exceed this, the rows are written as a
15484    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
15485    pub fn set_spill_threshold(&self, bytes: u64) {
15486        self.spill_threshold
15487            .store(bytes, std::sync::atomic::Ordering::Relaxed);
15488    }
15489
15490    /// Test-only: install a hook invoked after a transaction writes its spill
15491    /// runs but before the sequencer, so a test can race `gc()` against an
15492    /// in-flight spill. Not part of the stable API.
15493    #[doc(hidden)]
15494    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15495        *self.spill_hook.lock() = Some(Box::new(f));
15496    }
15497
15498    /// Test-only: install a hook invoked while a spilled commit holds the
15499    /// security read gate and before it appends to the WAL.
15500    #[doc(hidden)]
15501    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15502        *self.security_commit_hook.lock() = Some(Box::new(f));
15503    }
15504
15505    /// Test-only: install a hook after transaction preparation and before the
15506    /// commit sequencer validates catalog generations.
15507    #[doc(hidden)]
15508    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15509        *self.catalog_commit_hook.lock() = Some(Box::new(f));
15510    }
15511
15512    /// Test-only: pause an online backup after its consistent boundary is
15513    /// captured but before the pinned immutable runs are copied.
15514    #[doc(hidden)]
15515    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15516        *self.backup_hook.lock() = Some(Box::new(f));
15517    }
15518
15519    /// Test-only: invoked after each successful FK parent-protection lock
15520    /// acquisition during constraint validation, so tests can rendezvous two
15521    /// committing transactions into a deterministic wait-for cycle.
15522    #[doc(hidden)]
15523    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15524        *self.fk_lock_hook.lock() = Some(Arc::new(f));
15525    }
15526
15527    /// Test-only: pause WAL extraction before its final principal recheck.
15528    #[doc(hidden)]
15529    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15530        *self.replication_hook.lock() = Some(Box::new(f));
15531    }
15532
15533    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
15534    /// this stays well below the number of committed transactions when commits
15535    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
15536    #[doc(hidden)]
15537    pub fn __wal_group_sync_count(&self) -> u64 {
15538        self.shared_wal.lock().group_sync_count()
15539    }
15540
15541    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
15542    /// contract that an fsync error would trigger in production.
15543    #[doc(hidden)]
15544    pub fn __poison(&self) {
15545        self.poisoned
15546            .store(true, std::sync::atomic::Ordering::Relaxed);
15547    }
15548
15549    /// Verify multi-table integrity (spec §16). For every live table this:
15550    /// authenticates the manifest; opens each `RunRef`'s file through
15551    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
15552    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
15553    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
15554    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
15555    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
15556    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
15557    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
15558    ///
15559    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
15560    /// full body, so this is an integrity tool, not a hot path.
15561    pub fn check(&self) -> Vec<CheckIssue> {
15562        match self.check_inner(None) {
15563            Ok(issues) => issues,
15564            Err(error) => vec![CheckIssue {
15565                table_id: WAL_TABLE_ID,
15566                table_name: "shared WAL".into(),
15567                severity: "error".into(),
15568                description: error.to_string(),
15569            }],
15570        }
15571    }
15572
15573    /// Integrity check with cooperative cancellation between tables and runs.
15574    #[doc(hidden)]
15575    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
15576        self.check_inner(Some(control))
15577    }
15578
15579    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
15580        let mut issues = Vec::new();
15581        let io_root = self.durable_root.io_path()?;
15582        let cat = self.catalog.read();
15583        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
15584        for (table_index, entry) in cat.tables.iter().enumerate() {
15585            if table_index % 256 == 0 {
15586                if let Some(control) = control {
15587                    control.checkpoint()?;
15588                }
15589            }
15590            if !matches!(entry.state, TableState::Live) {
15591                continue;
15592            }
15593            let tdir = io_root.join(TABLES_DIR).join(entry.table_id.to_string());
15594            let mut err = |sev: &str, desc: String| {
15595                issues.push(CheckIssue {
15596                    table_id: entry.table_id,
15597                    table_name: entry.name.clone(),
15598                    severity: sev.into(),
15599                    description: desc,
15600                });
15601            };
15602            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
15603                Ok(m) => m,
15604                Err(e) => {
15605                    err("error", format!("manifest read failed: {e}"));
15606                    continue;
15607                }
15608            };
15609            if m.flushed_epoch > m.current_epoch {
15610                err(
15611                    "error",
15612                    format!(
15613                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
15614                        m.flushed_epoch, m.current_epoch
15615                    ),
15616                );
15617            }
15618
15619            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
15620            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
15621            for (run_index, rr) in m.runs.iter().enumerate() {
15622                if run_index % 256 == 0 {
15623                    if let Some(control) = control {
15624                        control.checkpoint()?;
15625                    }
15626                }
15627                referenced.insert(rr.run_id);
15628                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
15629                if !run_path.exists() {
15630                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
15631                    continue;
15632                }
15633                match crate::sorted_run::RunReader::open(
15634                    &run_path,
15635                    entry.schema.clone(),
15636                    self.kek.clone(),
15637                ) {
15638                    Ok(reader) => {
15639                        if reader.row_count() as u64 != rr.row_count {
15640                            err(
15641                                "error",
15642                                format!(
15643                                    "run r-{} row count mismatch: manifest {} vs run {}",
15644                                    rr.run_id,
15645                                    rr.row_count,
15646                                    reader.row_count()
15647                                ),
15648                            );
15649                        }
15650                    }
15651                    Err(e) => {
15652                        err(
15653                            "error",
15654                            format!("run r-{} integrity check failed: {e}", rr.run_id),
15655                        );
15656                    }
15657                }
15658            }
15659
15660            // Compaction-superseded runs awaiting retention-gated deletion are
15661            // tracked in `retiring`; their files are expected on disk, so they
15662            // are not orphans.
15663            for r in &m.retiring {
15664                referenced.insert(r.run_id);
15665            }
15666
15667            // Orphan `.sr` files present on disk but absent from the manifest.
15668            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
15669                for (entry_index, ent) in rd.flatten().enumerate() {
15670                    if entry_index % 256 == 0 {
15671                        if let Some(control) = control {
15672                            control.checkpoint()?;
15673                        }
15674                    }
15675                    let p = ent.path();
15676                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
15677                        continue;
15678                    }
15679                    let run_id = p
15680                        .file_stem()
15681                        .and_then(|s| s.to_str())
15682                        .and_then(|s| s.strip_prefix("r-"))
15683                        .and_then(|s| s.parse::<u128>().ok());
15684                    if let Some(id) = run_id {
15685                        if !referenced.contains(&id) {
15686                            err(
15687                                "warning",
15688                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
15689                            );
15690                        }
15691                    }
15692                }
15693            }
15694        }
15695
15696        let external_names = cat
15697            .external_tables
15698            .iter()
15699            .map(|entry| entry.name.clone())
15700            .collect::<std::collections::HashSet<_>>();
15701        let vtab_dir = io_root.join(VTAB_DIR);
15702        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
15703            for (entry_index, entry) in entries.flatten().enumerate() {
15704                if entry_index % 256 == 0 {
15705                    if let Some(control) = control {
15706                        control.checkpoint()?;
15707                    }
15708                }
15709                let name = entry.file_name();
15710                let Some(name) = name.to_str() else { continue };
15711                if !external_names.contains(name) {
15712                    issues.push(CheckIssue {
15713                        table_id: EXTERNAL_TABLE_ID,
15714                        table_name: name.to_string(),
15715                        severity: "warning".into(),
15716                        description: format!(
15717                            "orphan external table state entry {:?} not referenced by the catalog",
15718                            entry.path()
15719                        ),
15720                    });
15721                }
15722            }
15723        }
15724
15725        // WAL retention / integrity invariant (spec §16): every on-disk WAL
15726        // segment must open (header magic + version, and the frame cipher must
15727        // be derivable for an encrypted WAL). A segment that won't open is
15728        // corrupt or truncated and would break crash recovery. `table_id` is
15729        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
15730        // never confuses a WAL issue with a real table.
15731        if let Some(control) = control {
15732            control.checkpoint()?;
15733        }
15734        for (seg, msg) in self.shared_wal.lock().verify_segments() {
15735            issues.push(CheckIssue {
15736                table_id: WAL_TABLE_ID,
15737                table_name: "<wal>".into(),
15738                severity: "error".into(),
15739                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
15740            });
15741        }
15742        Ok(issues)
15743    }
15744
15745    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
15746    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
15747    /// unmounts them from the live table map so the DB still opens.
15748    pub fn doctor(&self) -> Result<Vec<u64>> {
15749        let control = crate::ExecutionControl::new(None);
15750        self.doctor_controlled(&control, || true)
15751    }
15752
15753    /// Check cancellably, then fence immediately before the first quarantine
15754    /// mutation. Returning `false` from `before_publish` leaves the database
15755    /// untouched.
15756    #[doc(hidden)]
15757    pub fn doctor_controlled<F>(
15758        &self,
15759        control: &crate::ExecutionControl,
15760        before_publish: F,
15761    ) -> Result<Vec<u64>>
15762    where
15763        F: FnOnce() -> bool,
15764    {
15765        self.doctor_controlled_with_receipt(control, before_publish)
15766            .map(|(quarantined, _)| quarantined)
15767    }
15768
15769    /// Check cancellably and return the exact catalog epoch used for a
15770    /// quarantine publication. No receipt is returned when nothing changes.
15771    #[doc(hidden)]
15772    pub fn doctor_controlled_with_receipt<F>(
15773        &self,
15774        control: &crate::ExecutionControl,
15775        before_publish: F,
15776    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
15777    where
15778        F: FnOnce() -> bool,
15779    {
15780        // Hold the DDL lock for the whole operation to prevent concurrent
15781        // create_table/drop_table from racing the catalog/dir mutation.
15782        let _ddl = self.ddl_lock.lock();
15783        let _security_write = self.security_write()?;
15784        let issues = self.check_inner(Some(control))?;
15785        // A corrupt WAL segment is reported as an error but is NOT a table
15786        // problem — quarantining an innocent table cannot fix it (and the first
15787        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
15788        // them disjoint). The admin must address WAL corruption manually.
15789        let bad_tables: std::collections::HashSet<u64> = issues
15790            .iter()
15791            .filter(|i| {
15792                i.severity == "error"
15793                    && i.table_id != WAL_TABLE_ID
15794                    && i.table_id != EXTERNAL_TABLE_ID
15795            })
15796            .map(|i| i.table_id)
15797            .collect();
15798        if bad_tables.is_empty() {
15799            return Ok((Vec::new(), None));
15800        }
15801        let _commit = self.commit_lock.lock();
15802        control.checkpoint()?;
15803        if !before_publish() {
15804            return Err(MongrelError::Cancelled);
15805        }
15806        let maintenance_epoch = self.epoch.bump_assigned();
15807        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
15808
15809        let qdir = self.root.join("_quarantine");
15810        crate::durable_file::create_directory(&qdir)?;
15811        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
15812        bad_tables.sort_unstable();
15813
15814        // Quiesce every mounted target before catalog publication. Existing
15815        // handle clones are marked unavailable in the publication callback so
15816        // they cannot append to the shared WAL after their catalog entry drops.
15817        let mut handles = self
15818            .tables
15819            .read()
15820            .iter()
15821            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
15822            .map(|(table_id, handle)| (*table_id, handle.clone()))
15823            .collect::<Vec<_>>();
15824        handles.sort_by_key(|(table_id, _)| *table_id);
15825        let mut table_guards = handles
15826            .iter()
15827            .map(|(table_id, handle)| (*table_id, handle.lock()))
15828            .collect::<Vec<_>>();
15829
15830        let mut next_catalog = self.catalog.read().clone();
15831        for table_id in &bad_tables {
15832            if let Some(entry) = next_catalog
15833                .tables
15834                .iter_mut()
15835                .find(|entry| entry.table_id == *table_id)
15836            {
15837                entry.state = TableState::Dropped {
15838                    at_epoch: maintenance_epoch.0,
15839                };
15840            }
15841        }
15842        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
15843
15844        let txn_id = self.alloc_txn_id()?;
15845        let commit_seq = {
15846            let mut wal = self.shared_wal.lock();
15847            let append: Result<u64> = (|| {
15848                for table_id in &bad_tables {
15849                    wal.append(
15850                        txn_id,
15851                        *table_id,
15852                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
15853                            table_id: *table_id,
15854                        }),
15855                    )?;
15856                }
15857                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15858                wal.append_commit(txn_id, maintenance_epoch, &[])
15859            })();
15860            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
15861        };
15862        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
15863        for (_, table) in &mut table_guards {
15864            table.mark_unavailable_after_quarantine();
15865        }
15866        {
15867            let mut live_tables = self.tables.write();
15868            for table_id in &bad_tables {
15869                live_tables.remove(table_id);
15870            }
15871        }
15872        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
15873        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
15874
15875        // Release DOCTOR's own table guards and handle clones before moving
15876        // the directory. Windows refuses to rename files held open by the
15877        // final mounted Table instance.
15878        drop(table_guards);
15879        drop(handles);
15880
15881        // The catalog drop is durable. Directory placement is secondary but
15882        // still uses a write-through rename. A failure reports the known
15883        // catalog outcome and leaves a harmless orphan under `tables/`.
15884        for table_id in &bad_tables {
15885            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
15886            if source.exists() {
15887                let destination = qdir.join(table_id.to_string());
15888                if let Err(error) = crate::durable_file::rename(&source, &destination) {
15889                    return Err(MongrelError::DurableCommit {
15890                        epoch: maintenance_epoch.0,
15891                        message: format!(
15892                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
15893                        ),
15894                    });
15895                }
15896            }
15897        }
15898        Ok((
15899            bad_tables,
15900            Some(MaintenanceReceipt {
15901                epoch: maintenance_epoch,
15902            }),
15903        ))
15904    }
15905
15906    /// The DB-wide KEK (if encrypted).
15907    #[allow(dead_code)]
15908    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
15909        self.kek.as_ref()
15910    }
15911
15912    /// Shared epoch authority (used by the transaction layer in P2).
15913    #[allow(dead_code)]
15914    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
15915        &self.epoch
15916    }
15917
15918    /// Shared snapshot registry (used by GC in P3.6).
15919    #[allow(dead_code)]
15920    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
15921        &self.snapshots
15922    }
15923}
15924
15925fn external_state_dir(root: &Path, name: &str) -> PathBuf {
15926    root.join(VTAB_DIR).join(name)
15927}
15928
15929fn append_catalog_snapshot(
15930    wal: &mut crate::wal::SharedWal,
15931    txn_id: u64,
15932    catalog: &Catalog,
15933) -> Result<()> {
15934    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
15935    wal.append(
15936        txn_id,
15937        WAL_TABLE_ID,
15938        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
15939    )?;
15940    Ok(())
15941}
15942
15943fn filter_ignored_staging(
15944    staging: Vec<(u64, crate::txn::Staged)>,
15945    ignored_indices: &std::collections::BTreeSet<usize>,
15946) -> Vec<(u64, crate::txn::Staged)> {
15947    if ignored_indices.is_empty() {
15948        return staging;
15949    }
15950    staging
15951        .into_iter()
15952        .enumerate()
15953        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
15954        .collect()
15955}
15956
15957fn external_state_file(root: &Path, name: &str) -> PathBuf {
15958    external_state_dir(root, name).join("state.json")
15959}
15960
15961fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
15962    let path = external_state_file(root, name);
15963    match std::fs::read(path) {
15964        Ok(bytes) => Ok(bytes),
15965        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
15966        Err(e) => Err(e.into()),
15967    }
15968}
15969
15970fn current_external_state_bytes(
15971    root: &Path,
15972    external_states: &[(String, Vec<u8>)],
15973    name: &str,
15974) -> Result<Vec<u8>> {
15975    for (table, state) in external_states.iter().rev() {
15976        if table == name {
15977            return Ok(state.clone());
15978        }
15979    }
15980    read_external_state_file(root, name)
15981}
15982
15983fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
15984    let mut out = external_states;
15985    dedup_external_states_in_place(&mut out);
15986    out
15987}
15988
15989fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
15990    let mut seen = std::collections::HashSet::new();
15991    let mut out = Vec::with_capacity(external_states.len());
15992    for (name, state) in std::mem::take(external_states).into_iter().rev() {
15993        if seen.insert(name.clone()) {
15994            out.push((name, state));
15995        }
15996    }
15997    out.reverse();
15998    *external_states = out;
15999}
16000
16001fn prepare_external_state_file(
16002    root: &Path,
16003    name: &str,
16004    state: &[u8],
16005    txn_id: u64,
16006) -> Result<PathBuf> {
16007    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
16008    let dir = external_state_dir(root, name);
16009    crate::durable_file::create_directory(&dir)?;
16010    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
16011    {
16012        let mut file = std::fs::OpenOptions::new()
16013            .create_new(true)
16014            .write(true)
16015            .open(&pending)?;
16016        file.write_all(state)?;
16017        file.sync_all()?;
16018    }
16019    Ok(pending)
16020}
16021
16022fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
16023    let path = external_state_file(root, name);
16024    crate::durable_file::replace(pending, &path)?;
16025    Ok(())
16026}
16027
16028fn write_external_state_file(
16029    durable: &crate::durable_file::DurableRoot,
16030    name: &str,
16031    state: &[u8],
16032) -> Result<()> {
16033    let directory = Path::new(VTAB_DIR).join(name);
16034    durable.create_directory_all(&directory)?;
16035    durable.write_atomic(directory.join("state.json"), state)?;
16036    Ok(())
16037}
16038
16039fn validate_recovered_data_table(
16040    catalog: &Catalog,
16041    tables: &HashMap<u64, TableHandle>,
16042    table_id: u64,
16043    commit_epoch: u64,
16044    offset: u64,
16045) -> Result<bool> {
16046    let entry = catalog
16047        .tables
16048        .iter()
16049        .find(|entry| entry.table_id == table_id)
16050        .ok_or_else(|| MongrelError::CorruptWal {
16051            offset,
16052            reason: format!("committed record references unknown table {table_id}"),
16053        })?;
16054    if commit_epoch < entry.created_epoch {
16055        return Err(MongrelError::CorruptWal {
16056            offset,
16057            reason: format!(
16058                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16059                entry.created_epoch
16060            ),
16061        });
16062    }
16063    match entry.state {
16064        TableState::Dropped { at_epoch } => {
16065            // Abandoned hidden builds are marked dropped at the last durable
16066            // boundary during open, so their final build commit may equal the
16067            // cleanup epoch. Ordinary table drops consume a new epoch and must
16068            // remain strictly later than every data commit.
16069            let abandoned_build_boundary =
16070                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16071            if commit_epoch >= at_epoch && !abandoned_build_boundary {
16072                Err(MongrelError::CorruptWal {
16073                    offset,
16074                    reason: format!(
16075                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16076                    ),
16077                })
16078            } else {
16079                Ok(false)
16080            }
16081        }
16082        TableState::Live | TableState::Building { .. } => {
16083            if tables.contains_key(&table_id) {
16084                Ok(true)
16085            } else {
16086                Err(MongrelError::CorruptWal {
16087                    offset,
16088                    reason: format!("live table {table_id} has no mounted recovery handle"),
16089                })
16090            }
16091        }
16092    }
16093}
16094
16095type RecoveryTableStage = (
16096    Vec<crate::memtable::Row>,
16097    Vec<(crate::rowid::RowId, Epoch)>,
16098    Option<Epoch>,
16099    Epoch,
16100);
16101
16102#[derive(Clone)]
16103struct RecoveryValidationTable {
16104    schema: Schema,
16105    flushed_epoch: u64,
16106}
16107
16108fn validate_shared_wal_recovery_plan(
16109    durable_root: &crate::durable_file::DurableRoot,
16110    catalog: &Catalog,
16111    recovered_table_ids: &HashSet<u64>,
16112    reconciled_table_ids: &HashSet<u64>,
16113    meta_dek: Option<&[u8; META_DEK_LEN]>,
16114    kek: Option<Arc<crate::encryption::Kek>>,
16115    records: &[crate::wal::Record],
16116) -> Result<()> {
16117    use crate::wal::{DdlOp, Op};
16118
16119    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
16120    for entry in &catalog.tables {
16121        if !matches!(entry.state, TableState::Live) {
16122            continue;
16123        }
16124        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
16125        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
16126            Ok(manifest) => Some(manifest),
16127            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
16128            Err(error) => return Err(error),
16129        };
16130        let flushed_epoch = if let Some(manifest) = manifest {
16131            if manifest.table_id != entry.table_id {
16132                return Err(MongrelError::Conflict(format!(
16133                    "catalog table {} storage identity mismatch",
16134                    entry.table_id
16135                )));
16136            }
16137            if (manifest.schema_id != entry.schema.schema_id
16138                && !reconciled_table_ids.contains(&entry.table_id))
16139                || manifest.flushed_epoch > manifest.current_epoch
16140                || manifest.global_idx_epoch > manifest.current_epoch
16141                || manifest.next_row_id == u64::MAX
16142                || manifest.auto_inc_next < 0
16143                || manifest.auto_inc_next == i64::MAX
16144                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
16145            {
16146                return Err(MongrelError::InvalidArgument(format!(
16147                    "table {} manifest counters or schema identity are invalid",
16148                    entry.table_id
16149                )));
16150            }
16151            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
16152            crate::global_idx::read_durable_for(
16153                durable_root,
16154                &relative_dir,
16155                entry.table_id,
16156                &entry.schema,
16157                idx_dek.as_deref(),
16158            )?;
16159            let mut run_ids = HashSet::new();
16160            let mut maximum_row_id = None::<u64>;
16161            for run in &manifest.runs {
16162                if run.run_id >= u64::MAX as u128
16163                    || run.epoch_created > manifest.current_epoch
16164                    || !run_ids.insert(run.run_id)
16165                {
16166                    return Err(MongrelError::InvalidArgument(format!(
16167                        "table {} manifest contains an invalid or duplicate run id",
16168                        entry.table_id
16169                    )));
16170                }
16171                let relative = relative_dir
16172                    .join(crate::engine::RUNS_DIR)
16173                    .join(format!("r-{}.sr", run.run_id as u64));
16174                let file = durable_root.open_regular(&relative)?;
16175                let mut reader = crate::sorted_run::RunReader::open_file(
16176                    file,
16177                    entry.schema.clone(),
16178                    kek.clone(),
16179                )?;
16180                let header = reader.header();
16181                if header.run_id != run.run_id
16182                    || header.level != run.level
16183                    || header.row_count != run.row_count
16184                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
16185                    || header.is_uniform_epoch() && header.epoch_created != 0
16186                    || header.schema_id > entry.schema.schema_id
16187                {
16188                    return Err(MongrelError::InvalidArgument(format!(
16189                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
16190                        entry.table_id,
16191                        run.run_id,
16192                        header.run_id,
16193                        header.level,
16194                        header.row_count,
16195                        header.epoch_created,
16196                        header.schema_id,
16197                        run.run_id,
16198                        run.level,
16199                        run.row_count,
16200                        run.epoch_created,
16201                        entry.schema.schema_id,
16202                    )));
16203                }
16204                if header.row_count != 0 {
16205                    maximum_row_id = Some(
16206                        maximum_row_id
16207                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
16208                    );
16209                }
16210                reader.validate_all_pages()?;
16211            }
16212            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
16213                return Err(MongrelError::InvalidArgument(format!(
16214                    "table {} next_row_id does not advance beyond persisted rows",
16215                    entry.table_id
16216                )));
16217            }
16218            for run in &manifest.retiring {
16219                if run.run_id >= u64::MAX as u128
16220                    || run.retire_epoch > manifest.current_epoch
16221                    || !run_ids.insert(run.run_id)
16222                {
16223                    return Err(MongrelError::InvalidArgument(format!(
16224                        "table {} manifest contains an invalid or aliased retired run",
16225                        entry.table_id
16226                    )));
16227                }
16228            }
16229            manifest.flushed_epoch
16230        } else {
16231            if !recovered_table_ids.contains(&entry.table_id) {
16232                return Err(MongrelError::NotFound(format!(
16233                    "live table {} manifest is missing",
16234                    entry.table_id
16235                )));
16236            }
16237            0
16238        };
16239        tables.insert(
16240            entry.table_id,
16241            RecoveryValidationTable {
16242                schema: entry.schema.clone(),
16243                flushed_epoch,
16244            },
16245        );
16246    }
16247
16248    let committed = records
16249        .iter()
16250        .filter_map(|record| match record.op {
16251            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
16252            _ => None,
16253        })
16254        .collect::<HashMap<_, _>>();
16255    let mut run_ids = HashSet::new();
16256    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
16257    for record in records {
16258        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
16259            continue;
16260        };
16261        match &record.op {
16262            Op::Put { table_id, rows } => {
16263                let table = validate_recovery_data_table_plan(
16264                    catalog,
16265                    &tables,
16266                    *table_id,
16267                    commit_epoch,
16268                    record.seq.0,
16269                )?;
16270                let decoded: Vec<crate::memtable::Row> =
16271                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
16272                        offset: record.seq.0,
16273                        reason: format!(
16274                            "committed Put payload for transaction {} could not be decoded: {error}",
16275                            record.txn_id
16276                        ),
16277                    })?;
16278                if let Some(table) = table {
16279                    for row in &decoded {
16280                        if !recovered_row_ids
16281                            .entry(*table_id)
16282                            .or_default()
16283                            .insert(row.row_id.0)
16284                        {
16285                            return Err(MongrelError::CorruptWal {
16286                                offset: record.seq.0,
16287                                reason: format!(
16288                                    "committed WAL repeats recovered row id {} for table {table_id}",
16289                                    row.row_id.0
16290                                ),
16291                            });
16292                        }
16293                        validate_recovered_row(&table.schema, row)?;
16294                    }
16295                }
16296            }
16297            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
16298                validate_recovery_data_table_plan(
16299                    catalog,
16300                    &tables,
16301                    *table_id,
16302                    commit_epoch,
16303                    record.seq.0,
16304                )?;
16305            }
16306            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
16307            Op::Ddl(DdlOp::ResetExternalTableState {
16308                name,
16309                generation_epoch,
16310            }) => {
16311                if *generation_epoch != commit_epoch {
16312                    return Err(MongrelError::CorruptWal {
16313                        offset: record.seq.0,
16314                        reason: format!(
16315                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
16316                        ),
16317                    });
16318                }
16319                validate_recovered_external_name(name)?;
16320            }
16321            Op::TxnCommit { added_runs, .. } => {
16322                for added in added_runs {
16323                    let Some(table) = validate_recovery_data_table_plan(
16324                        catalog,
16325                        &tables,
16326                        added.table_id,
16327                        commit_epoch,
16328                        record.seq.0,
16329                    )?
16330                    else {
16331                        continue;
16332                    };
16333                    if added.run_id >= u64::MAX as u128
16334                        || !run_ids.insert((added.table_id, added.run_id))
16335                    {
16336                        return Err(MongrelError::CorruptWal {
16337                            offset: record.seq.0,
16338                            reason: format!(
16339                                "duplicate or invalid recovered run {} for table {}",
16340                                added.run_id, added.table_id
16341                            ),
16342                        });
16343                    }
16344                    if commit_epoch <= table.flushed_epoch {
16345                        continue;
16346                    }
16347                    validate_planned_spilled_run(
16348                        durable_root,
16349                        record.txn_id,
16350                        commit_epoch,
16351                        added,
16352                        &table.schema,
16353                        kek.clone(),
16354                    )?;
16355                }
16356            }
16357            _ => {}
16358        }
16359    }
16360    Ok(())
16361}
16362
16363fn validate_recovery_data_table_plan<'a>(
16364    catalog: &Catalog,
16365    tables: &'a HashMap<u64, RecoveryValidationTable>,
16366    table_id: u64,
16367    commit_epoch: u64,
16368    offset: u64,
16369) -> Result<Option<&'a RecoveryValidationTable>> {
16370    let entry = catalog
16371        .tables
16372        .iter()
16373        .find(|entry| entry.table_id == table_id)
16374        .ok_or_else(|| MongrelError::CorruptWal {
16375            offset,
16376            reason: format!("committed record references unknown table {table_id}"),
16377        })?;
16378    if commit_epoch < entry.created_epoch {
16379        return Err(MongrelError::CorruptWal {
16380            offset,
16381            reason: format!(
16382                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16383                entry.created_epoch
16384            ),
16385        });
16386    }
16387    match entry.state {
16388        TableState::Dropped { at_epoch } => {
16389            let abandoned =
16390                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16391            if commit_epoch >= at_epoch && !abandoned {
16392                return Err(MongrelError::CorruptWal {
16393                    offset,
16394                    reason: format!(
16395                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16396                    ),
16397                });
16398            }
16399            Ok(None)
16400        }
16401        TableState::Live => {
16402            tables
16403                .get(&table_id)
16404                .map(Some)
16405                .ok_or_else(|| MongrelError::CorruptWal {
16406                    offset,
16407                    reason: format!("live table {table_id} has no recovery plan"),
16408                })
16409        }
16410        TableState::Building { .. } => Err(MongrelError::CorruptWal {
16411            offset,
16412            reason: format!("building table {table_id} was not normalized before recovery"),
16413        }),
16414    }
16415}
16416
16417fn validate_planned_spilled_run(
16418    root: &crate::durable_file::DurableRoot,
16419    txn_id: u64,
16420    commit_epoch: u64,
16421    added: &crate::wal::AddedRun,
16422    schema: &Schema,
16423    kek: Option<Arc<crate::encryption::Kek>>,
16424) -> Result<()> {
16425    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
16426    let destination = table
16427        .join(crate::engine::RUNS_DIR)
16428        .join(format!("r-{}.sr", added.run_id as u64));
16429    let pending = table
16430        .join("_txn")
16431        .join(txn_id.to_string())
16432        .join(format!("r-{}.sr", added.run_id as u64));
16433    let file = match root.open_regular(&destination) {
16434        Ok(file) => file,
16435        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
16436            root.open_regular(&pending).map_err(|pending_error| {
16437                if pending_error.kind() == std::io::ErrorKind::NotFound {
16438                    MongrelError::CorruptWal {
16439                        offset: commit_epoch,
16440                        reason: format!(
16441                            "committed spilled run {} for transaction {txn_id} is missing",
16442                            added.run_id
16443                        ),
16444                    }
16445                } else {
16446                    pending_error.into()
16447                }
16448            })?
16449        }
16450        Err(error) => return Err(error.into()),
16451    };
16452    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
16453    let header = reader.header();
16454    if header.run_id != added.run_id
16455        || header.content_hash != added.content_hash
16456        || header.row_count != added.row_count
16457        || header.level != added.level
16458        || header.min_row_id != added.min_row_id
16459        || header.max_row_id != added.max_row_id
16460        || header.schema_id != schema.schema_id
16461        || !header.is_uniform_epoch()
16462        || header.epoch_created != 0
16463    {
16464        return Err(MongrelError::CorruptWal {
16465            offset: commit_epoch,
16466            reason: format!(
16467                "committed spilled run {} metadata differs from WAL",
16468                added.run_id
16469            ),
16470        });
16471    }
16472    reader.validate_all_pages()?;
16473    Ok(())
16474}
16475
16476/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
16477///
16478/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
16479/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
16480/// 2 applies each committed data record (Put/Delete) to its table at the commit
16481/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
16482/// durable in a sorted run). Finally the shared epoch authority is raised to the
16483/// max committed epoch so the next commit continues monotonically.
16484/// The staged-write payload contract of a distributed-transaction write
16485/// intent (spec section 12.8). A participant in two-phase commit stages its
16486/// writes as opaque intent payloads (`WriteIntent::value_ref` in
16487/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
16488/// this engine-defined encoding is the whole contract. At prepare time the
16489/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
16490/// committed resolution they are applied through
16491/// [`Database::apply_staged_txn_writes`].
16492///
16493/// The encoding is bincode over this enum (the same codec the WAL frame
16494/// payloads use); discriminants are never reused.
16495#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16496pub enum StagedTxnWrite {
16497    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
16498    /// identical payload shape an `Op::Put` WAL record carries). Row commit
16499    /// epochs are placeholders — the resolution apply restamps every row at
16500    /// the synthetic commit epoch.
16501    Put {
16502        /// The mounted table the rows target.
16503        table_id: u64,
16504        /// Bincode `Vec<crate::memtable::Row>`.
16505        rows: Vec<u8>,
16506    },
16507    /// Staged row deletes by row id.
16508    Delete {
16509        /// The mounted table the deletes target.
16510        table_id: u64,
16511        /// Row ids (`crate::RowId` values) to delete.
16512        row_ids: Vec<u64>,
16513    },
16514}
16515
16516impl StagedTxnWrite {
16517    /// Serializes deterministically (bincode over the enum).
16518    pub fn encode(&self) -> Result<Vec<u8>> {
16519        Ok(bincode::serialize(self)?)
16520    }
16521
16522    /// Decodes one staged-write payload, failing closed on malformed input.
16523    pub fn decode(bytes: &[u8]) -> Result<Self> {
16524        Ok(bincode::deserialize(bytes)?)
16525    }
16526}
16527
16528/// Leader-side spill translation for the replicated write path (spec section
16529/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
16530///
16531/// A transaction whose staged puts exceed the spill threshold commits with
16532/// its rows in a leader-local sorted run: the commit marker's `added_runs`
16533/// links the run file and the rows also ride the WAL as logical
16534/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
16535/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
16536/// and because the raft entry is already quorum-committed, an apply-time
16537/// rejection wedges the whole group's apply stream. The leader therefore
16538/// translates the staged record sequence **before proposal**:
16539///
16540/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
16541///   (identical row bytes; recovery restamps the rows at the commit epoch),
16542/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
16543///   reach a replica),
16544/// - every other record passes through byte-identical.
16545///
16546/// The standalone commit path is untouched: the leader's own WAL keeps the
16547/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
16548/// links the run; this function reads but never mutates its input.
16549///
16550/// Translation is total for any sequence the commit sequencer actually
16551/// produced. As a fail-closed guard against malformed or truncated captures,
16552/// the sequence is structurally validated (one transaction, exactly one
16553/// trailing commit marker), every spill payload must decode, and every linked
16554/// run's rows must be provably present as logical records (the row-id range
16555/// covers `row_count` rows); a violation rejects the proposal with
16556/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
16557/// post-commit. (Taxonomy: `InvalidArgument` maps to
16558/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
16559/// disagreement that is never retried unchanged. The normative spec category
16560/// for "the commit was not applied; only a fresh transaction may succeed" is
16561/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
16562/// `error.rs`, which is outside this change's file scope — tracked as a
16563/// follow-up.)
16564pub fn translate_records_for_replication(
16565    records: &[crate::wal::Record],
16566) -> Result<Vec<crate::wal::Record>> {
16567    use crate::wal::Op;
16568
16569    // Structural validation mirrors `apply_replicated_records`: one
16570    // transaction, exactly one commit marker, at the tail.
16571    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
16572        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
16573    })?;
16574    if records.iter().any(|record| record.txn_id != txn_id) {
16575        return Err(MongrelError::InvalidArgument(
16576            "replicated transaction payload mixes transaction ids".into(),
16577        ));
16578    }
16579    let commits = records
16580        .iter()
16581        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
16582        .count();
16583    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
16584        return Err(MongrelError::InvalidArgument(
16585            "replicated transaction payload must end in exactly one commit marker".into(),
16586        ));
16587    }
16588
16589    // Decode every logical spill payload now: a payload that cannot decode
16590    // here would fail every replica's apply identically — reject at propose
16591    // time instead.
16592    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
16593    for record in records {
16594        if let Op::SpilledRows { table_id, rows } = &record.op {
16595            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
16596                MongrelError::InvalidArgument(format!(
16597                    "spilled row payload for table {table_id} cannot decode for replication: \
16598                         {error}"
16599                ))
16600            })?;
16601            spilled_rows.entry(*table_id).or_default().extend(chunk);
16602        }
16603    }
16604
16605    // Coverage proof: every run the commit marker links must have its full
16606    // row content present as logical records, or replicas would silently
16607    // lose those rows.
16608    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
16609        unreachable!("one trailing commit marker validated above");
16610    };
16611    for run in added_runs {
16612        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
16613            MongrelError::InvalidArgument(format!(
16614                "commit links spilled run {} for table {} but carries no logical row records \
16615                 for it",
16616                run.run_id, run.table_id
16617            ))
16618        })?;
16619        let covered = rows
16620            .iter()
16621            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
16622            .count() as u64;
16623        if covered != run.row_count {
16624            return Err(MongrelError::InvalidArgument(format!(
16625                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
16626                 row records cover {} rows",
16627                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
16628            )));
16629        }
16630    }
16631
16632    // Translate: spill payloads become ordinary puts; the commit marker no
16633    // longer references leader-local run files.
16634    let translated = records
16635        .iter()
16636        .map(|record| {
16637            let op = match &record.op {
16638                Op::SpilledRows { table_id, rows } => Op::Put {
16639                    table_id: *table_id,
16640                    rows: rows.clone(),
16641                },
16642                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
16643                    epoch: *epoch,
16644                    added_runs: Vec::new(),
16645                },
16646                op => op.clone(),
16647            };
16648            crate::wal::Record::new(record.seq, record.txn_id, op)
16649        })
16650        .collect();
16651    Ok(translated)
16652}
16653
16654fn recover_shared_wal(
16655    durable_root: &crate::durable_file::DurableRoot,
16656    tables: &HashMap<u64, TableHandle>,
16657    catalog: &Catalog,
16658    epoch: &EpochAuthority,
16659    records: &[crate::wal::Record],
16660) -> Result<()> {
16661    use crate::memtable::Row;
16662    use crate::wal::{DdlOp, Op};
16663
16664    // Pass 1: committed-txn outcomes + collect spilled-run info.
16665    let mut committed: HashMap<u64, u64> = HashMap::new();
16666    let mut spilled_to_link: Vec<(
16667        u64, /*txn_id*/
16668        u64, /*epoch*/
16669        Vec<crate::wal::AddedRun>,
16670    )> = Vec::new();
16671    for r in records {
16672        if let Op::TxnCommit {
16673            epoch: ce,
16674            ref added_runs,
16675        } = r.op
16676        {
16677            committed.insert(r.txn_id, ce);
16678            if !added_runs.is_empty() {
16679                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
16680            }
16681        }
16682    }
16683    for record in records {
16684        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
16685            continue;
16686        };
16687        match &record.op {
16688            Op::Put { table_id, .. }
16689            | Op::Delete { table_id, .. }
16690            | Op::TruncateTable { table_id } => {
16691                validate_recovered_data_table(
16692                    catalog,
16693                    tables,
16694                    *table_id,
16695                    commit_epoch,
16696                    record.seq.0,
16697                )?;
16698            }
16699            Op::TxnCommit { added_runs, .. } => {
16700                for run in added_runs {
16701                    validate_recovered_data_table(
16702                        catalog,
16703                        tables,
16704                        run.table_id,
16705                        commit_epoch,
16706                        record.seq.0,
16707                    )?;
16708                }
16709            }
16710            _ => {}
16711        }
16712    }
16713    let truncated_transactions: HashSet<(u64, u64)> = records
16714        .iter()
16715        .filter_map(|record| {
16716            committed.get(&record.txn_id)?;
16717            match record.op {
16718                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
16719                _ => None,
16720            }
16721        })
16722        .collect();
16723
16724    // Pass 2: stage data per table, gated by flushed_epoch.
16725    enum ExternalRecoveryAction {
16726        Write { name: String, state: Vec<u8> },
16727        Reset { name: String },
16728    }
16729    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
16730    let mut external_actions = Vec::new();
16731    let mut max_epoch = epoch.visible().0;
16732    for r in records.iter().cloned() {
16733        let Some(&ce) = committed.get(&r.txn_id) else {
16734            continue; // aborted / in-flight — discard
16735        };
16736        let commit_epoch = Epoch(ce);
16737        max_epoch = max_epoch.max(ce);
16738        match r.op {
16739            Op::Put { table_id, rows } => {
16740                // Skip if this table already flushed past the commit epoch.
16741                let skip = tables
16742                    .get(&table_id)
16743                    .map(|h| h.lock().flushed_epoch() >= ce)
16744                    .unwrap_or(true);
16745                if skip {
16746                    continue;
16747                }
16748                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
16749                    MongrelError::CorruptWal {
16750                        offset: r.seq.0,
16751                        reason: format!(
16752                            "committed Put payload for transaction {} could not be decoded: {error}",
16753                            r.txn_id
16754                        ),
16755                    }
16756                })?;
16757                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
16758                // at pending_epoch which equals the commit epoch, but be robust).
16759                let rows: Vec<Row> = rows
16760                    .into_iter()
16761                    .map(|mut row| {
16762                        row.committed_epoch = commit_epoch;
16763                        row
16764                    })
16765                    .collect();
16766                let entry = stage
16767                    .entry(table_id)
16768                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
16769                entry.0.extend(rows);
16770                entry.3 = commit_epoch;
16771            }
16772            Op::Delete { table_id, row_ids } => {
16773                let skip = tables
16774                    .get(&table_id)
16775                    .map(|h| h.lock().flushed_epoch() >= ce)
16776                    .unwrap_or(true);
16777                if skip {
16778                    continue;
16779                }
16780                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
16781                let entry = stage
16782                    .entry(table_id)
16783                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
16784                entry.1.extend(dels);
16785                entry.3 = commit_epoch;
16786            }
16787            Op::TruncateTable { table_id } => {
16788                let skip = tables
16789                    .get(&table_id)
16790                    .map(|h| h.lock().flushed_epoch() >= ce)
16791                    .unwrap_or(true);
16792                if skip {
16793                    continue;
16794                }
16795                stage.insert(
16796                    table_id,
16797                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
16798                );
16799            }
16800            Op::ExternalTableState { name, state } => {
16801                let current_generation = catalog
16802                    .external_tables
16803                    .iter()
16804                    .find(|entry| entry.name == name)
16805                    .map(|entry| entry.created_epoch);
16806                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
16807                    validate_recovered_external_name(&name)?;
16808                    external_actions.push(ExternalRecoveryAction::Write { name, state });
16809                }
16810            }
16811            Op::Ddl(DdlOp::ResetExternalTableState {
16812                name,
16813                generation_epoch,
16814            }) => {
16815                if generation_epoch != ce {
16816                    return Err(MongrelError::CorruptWal {
16817                        offset: r.seq.0,
16818                        reason: format!(
16819                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
16820                    ),
16821                    });
16822                }
16823                validate_recovered_external_name(&name)?;
16824                external_actions.push(ExternalRecoveryAction::Reset { name });
16825            }
16826            Op::Flush { .. }
16827            | Op::TxnCommit { .. }
16828            | Op::TxnAbort
16829            | Op::Ddl(_)
16830            | Op::BeforeImage { .. }
16831            | Op::CommitTimestamp { .. }
16832            | Op::SpilledRows { .. } => {}
16833        }
16834    }
16835    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
16836        added_runs.retain(|added| {
16837            tables
16838                .get(&added.table_id)
16839                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
16840        });
16841    }
16842    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
16843    validate_recovery_table_stages(tables, &stage)?;
16844    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
16845
16846    // All WAL payloads, catalog generations, table stages, and immutable run
16847    // identities have now been validated. Only this application phase mutates
16848    // the database tree.
16849    for action in external_actions {
16850        match action {
16851            ExternalRecoveryAction::Write { name, state } => {
16852                write_external_state_file(durable_root, &name, &state)?;
16853            }
16854            ExternalRecoveryAction::Reset { name } => {
16855                durable_root.create_directory_all(VTAB_DIR)?;
16856                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
16857            }
16858        }
16859    }
16860    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
16861        let Some(handle) = tables.get(&table_id) else {
16862            continue;
16863        };
16864        let mut t = handle.lock();
16865        if let Some(epoch) = truncate_epoch {
16866            t.apply_truncate(epoch);
16867        }
16868        t.recover_apply(rows, deletes)?;
16869        // The WAL can be newer than the copied/persisted manifest after a
16870        // crash or replication apply. Rebuild O(1) count metadata from the
16871        // recovered state before endorsing the commit epoch in the manifest.
16872        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
16873        t.live_count = rows.len() as u64;
16874        // Recovery can replay older row commits while a newer spilled run is
16875        // already linked by the copied manifest. Never move that manifest's
16876        // epoch behind its existing run references.
16877        t.persist_manifest(table_epoch.max(epoch.visible()))?;
16878    }
16879
16880    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
16881    // between TxnCommit sync and the publish phase leaves the run in
16882    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
16883    for (txn_id, ce, added_runs) in &spilled_to_link {
16884        for ar in added_runs {
16885            let Some(handle) = tables.get(&ar.table_id) else {
16886                continue;
16887            };
16888            let mut t = handle.lock();
16889            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
16890            let destination = table_dir
16891                .join(crate::engine::RUNS_DIR)
16892                .join(format!("r-{}.sr", ar.run_id));
16893            match durable_root.open_regular(&destination) {
16894                Ok(_) => {}
16895                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
16896                    let pending = table_dir
16897                        .join("_txn")
16898                        .join(txn_id.to_string())
16899                        .join(format!("r-{}.sr", ar.run_id));
16900                    durable_root.rename_file_new(&pending, &destination)?;
16901                }
16902                Err(error) => return Err(error.into()),
16903            }
16904            // Only link a run whose file is actually present, and never re-link
16905            // one the publish phase already persisted into the manifest (which is
16906            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
16907            // until segment GC). `recover_spilled_run` is idempotent + reconciles
16908            // `live_count`/indexes only when the run is genuinely new.
16909            let linked = t.recover_spilled_run(crate::manifest::RunRef {
16910                run_id: ar.run_id,
16911                level: ar.level,
16912                epoch_created: *ce,
16913                row_count: ar.row_count,
16914            });
16915            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
16916            if replaced {
16917                t.set_flushed_epoch(Epoch(*ce));
16918            }
16919            if linked || replaced {
16920                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
16921            }
16922        }
16923    }
16924
16925    epoch.advance_recovered(Epoch(max_epoch));
16926    Ok(())
16927}
16928
16929fn reconcile_recovered_table_metadata(
16930    tables: &HashMap<u64, TableHandle>,
16931    epoch: Epoch,
16932) -> Result<()> {
16933    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
16934    table_ids.sort_unstable();
16935    let mut plans = Vec::with_capacity(table_ids.len());
16936    for table_id in &table_ids {
16937        let handle = tables.get(table_id).ok_or_else(|| {
16938            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
16939        })?;
16940        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
16941    }
16942    // Every table's data and metadata have been decoded successfully. Publish
16943    // repairs only after the complete database-wide plan is known valid.
16944    for (table_id, plan) in plans {
16945        let handle = tables.get(&table_id).ok_or_else(|| {
16946            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
16947        })?;
16948        handle.lock().apply_recovered_metadata(plan, epoch)?;
16949    }
16950    Ok(())
16951}
16952
16953fn validate_recovered_external_name(name: &str) -> Result<()> {
16954    if name.is_empty()
16955        || !name.chars().all(|character| {
16956            character.is_ascii_alphanumeric() || character == '_' || character == '-'
16957        })
16958    {
16959        return Err(MongrelError::CorruptWal {
16960            offset: 0,
16961            reason: format!("unsafe recovered external-table name {name:?}"),
16962        });
16963    }
16964    Ok(())
16965}
16966
16967fn validate_recovery_table_stages(
16968    tables: &HashMap<u64, TableHandle>,
16969    stages: &HashMap<u64, RecoveryTableStage>,
16970) -> Result<()> {
16971    for (table_id, (rows, _, _, _)) in stages {
16972        let handle = tables
16973            .get(table_id)
16974            .ok_or_else(|| MongrelError::CorruptWal {
16975                offset: *table_id,
16976                reason: format!("recovery stage references unmounted table {table_id}"),
16977            })?;
16978        let table = handle.lock();
16979        // Force all existing immutable runs through their integrity/decode path
16980        // before any other table manifest can be changed.
16981        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
16982        for row in rows {
16983            validate_recovered_row(table.schema(), row)?;
16984        }
16985    }
16986    Ok(())
16987}
16988
16989fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
16990    if row.deleted || row.row_id.0 == u64::MAX {
16991        return Err(MongrelError::CorruptWal {
16992            offset: row.row_id.0,
16993            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
16994        });
16995    }
16996    let cells = row
16997        .columns
16998        .iter()
16999        .map(|(column, value)| (*column, value.clone()))
17000        .collect::<Vec<_>>();
17001    schema
17002        .validate_persisted_values(&cells)
17003        .map_err(|error| MongrelError::CorruptWal {
17004            offset: row.row_id.0,
17005            reason: format!("recovered row violates table schema: {error}"),
17006        })?;
17007    if schema.auto_increment_column().is_some_and(|column| {
17008        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
17009    }) {
17010        return Err(MongrelError::CorruptWal {
17011            offset: row.row_id.0,
17012            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
17013        });
17014    }
17015    Ok(())
17016}
17017
17018fn validate_recovery_spilled_runs(
17019    root: &crate::durable_file::DurableRoot,
17020    tables: &HashMap<u64, TableHandle>,
17021    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
17022) -> Result<()> {
17023    let mut identities = HashSet::new();
17024    for (txn_id, commit_epoch, added_runs) in spilled {
17025        for added in added_runs {
17026            if added.run_id >= u64::MAX as u128 {
17027                return Err(MongrelError::CorruptWal {
17028                    offset: *commit_epoch,
17029                    reason: format!(
17030                        "recovered run id {} exceeds the on-disk namespace",
17031                        added.run_id
17032                    ),
17033                });
17034            }
17035            let Some(handle) = tables.get(&added.table_id) else {
17036                continue;
17037            };
17038            if !identities.insert((added.table_id, added.run_id)) {
17039                return Err(MongrelError::CorruptWal {
17040                    offset: *commit_epoch,
17041                    reason: format!(
17042                        "duplicate recovered run {} for table {}",
17043                        added.run_id, added.table_id
17044                    ),
17045                });
17046            }
17047            let table = handle.lock();
17048            validate_planned_spilled_run(
17049                root,
17050                *txn_id,
17051                *commit_epoch,
17052                added,
17053                table.schema(),
17054                table.kek(),
17055            )?;
17056        }
17057    }
17058    Ok(())
17059}
17060
17061fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
17062    match condition {
17063        ProcedureCondition::Pk { .. } => {
17064            if schema.primary_key().is_none() {
17065                return Err(MongrelError::InvalidArgument(
17066                    "procedure condition Pk references a table without a primary key".into(),
17067                ));
17068            }
17069        }
17070        ProcedureCondition::BitmapEq { column_id, .. }
17071        | ProcedureCondition::BitmapIn { column_id, .. }
17072        | ProcedureCondition::Range { column_id, .. }
17073        | ProcedureCondition::RangeF64 { column_id, .. }
17074        | ProcedureCondition::IsNull { column_id }
17075        | ProcedureCondition::IsNotNull { column_id }
17076        | ProcedureCondition::FmContains { column_id, .. } => {
17077            validate_column_id(*column_id, schema)?;
17078        }
17079    }
17080    Ok(())
17081}
17082
17083fn bind_procedure_args(
17084    procedure: &StoredProcedure,
17085    mut args: HashMap<String, crate::Value>,
17086) -> Result<HashMap<String, crate::Value>> {
17087    let mut out = HashMap::new();
17088    for param in &procedure.params {
17089        let value = match args.remove(&param.name) {
17090            Some(value) => value,
17091            None => param.default.clone().ok_or_else(|| {
17092                MongrelError::InvalidArgument(format!(
17093                    "missing required procedure parameter {:?}",
17094                    param.name
17095                ))
17096            })?,
17097        };
17098        if !param.nullable && matches!(value, crate::Value::Null) {
17099            return Err(MongrelError::InvalidArgument(format!(
17100                "procedure parameter {:?} must not be NULL",
17101                param.name
17102            )));
17103        }
17104        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
17105            return Err(MongrelError::InvalidArgument(format!(
17106                "procedure parameter {:?} has wrong type",
17107                param.name
17108            )));
17109        }
17110        out.insert(param.name.clone(), value);
17111    }
17112    if let Some(extra) = args.keys().next() {
17113        return Err(MongrelError::InvalidArgument(format!(
17114            "unknown procedure parameter {extra:?}"
17115        )));
17116    }
17117    Ok(out)
17118}
17119
17120fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
17121    matches!(
17122        (value, ty),
17123        (crate::Value::Bool(_), crate::TypeId::Bool)
17124            | (crate::Value::Int64(_), crate::TypeId::Int8)
17125            | (crate::Value::Int64(_), crate::TypeId::Int16)
17126            | (crate::Value::Int64(_), crate::TypeId::Int32)
17127            | (crate::Value::Int64(_), crate::TypeId::Int64)
17128            | (crate::Value::Int64(_), crate::TypeId::UInt8)
17129            | (crate::Value::Int64(_), crate::TypeId::UInt16)
17130            | (crate::Value::Int64(_), crate::TypeId::UInt32)
17131            | (crate::Value::Int64(_), crate::TypeId::UInt64)
17132            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
17133            | (crate::Value::Int64(_), crate::TypeId::Date32)
17134            | (crate::Value::Float64(_), crate::TypeId::Float32)
17135            | (crate::Value::Float64(_), crate::TypeId::Float64)
17136            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
17137            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
17138            | (
17139                crate::Value::GeneratedEmbedding(_),
17140                crate::TypeId::Embedding { .. }
17141            )
17142    )
17143}
17144
17145fn eval_cells(
17146    cells: &[crate::procedure::ProcedureCell],
17147    args: &HashMap<String, crate::Value>,
17148    outputs: &HashMap<String, ProcedureCallOutput>,
17149) -> Result<Vec<(u16, crate::Value)>> {
17150    cells
17151        .iter()
17152        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
17153        .collect()
17154}
17155
17156fn eval_condition(
17157    condition: &ProcedureCondition,
17158    args: &HashMap<String, crate::Value>,
17159    outputs: &HashMap<String, ProcedureCallOutput>,
17160) -> Result<crate::Condition> {
17161    Ok(match condition {
17162        ProcedureCondition::Pk { value } => {
17163            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
17164        }
17165        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
17166            column_id: *column_id,
17167            value: eval_value(value, args, outputs)?.encode_key(),
17168        },
17169        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
17170            column_id: *column_id,
17171            values: values
17172                .iter()
17173                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
17174                .collect::<Result<Vec<_>>>()?,
17175        },
17176        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
17177            column_id: *column_id,
17178            lo: expect_i64(eval_value(lo, args, outputs)?)?,
17179            hi: expect_i64(eval_value(hi, args, outputs)?)?,
17180        },
17181        ProcedureCondition::RangeF64 {
17182            column_id,
17183            lo,
17184            lo_inclusive,
17185            hi,
17186            hi_inclusive,
17187        } => crate::Condition::RangeF64 {
17188            column_id: *column_id,
17189            lo: expect_f64(eval_value(lo, args, outputs)?)?,
17190            lo_inclusive: *lo_inclusive,
17191            hi: expect_f64(eval_value(hi, args, outputs)?)?,
17192            hi_inclusive: *hi_inclusive,
17193        },
17194        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
17195            column_id: *column_id,
17196        },
17197        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
17198            column_id: *column_id,
17199        },
17200        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
17201            column_id: *column_id,
17202            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
17203        },
17204    })
17205}
17206
17207fn eval_value(
17208    value: &ProcedureValue,
17209    args: &HashMap<String, crate::Value>,
17210    outputs: &HashMap<String, ProcedureCallOutput>,
17211) -> Result<crate::Value> {
17212    match value {
17213        ProcedureValue::Literal(value) => Ok(value.clone()),
17214        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
17215            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17216        }),
17217        ProcedureValue::StepScalar(id) => match outputs.get(id) {
17218            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
17219            _ => Err(MongrelError::InvalidArgument(format!(
17220                "procedure step {id:?} did not return a scalar"
17221            ))),
17222        },
17223        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
17224            Err(MongrelError::InvalidArgument(
17225                "row-valued procedure reference cannot be used as a scalar".into(),
17226            ))
17227        }
17228        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
17229            "structured procedure value cannot be used as a scalar cell".into(),
17230        )),
17231    }
17232}
17233
17234fn eval_return_output(
17235    value: &ProcedureValue,
17236    args: &HashMap<String, crate::Value>,
17237    outputs: &HashMap<String, ProcedureCallOutput>,
17238) -> Result<ProcedureCallOutput> {
17239    match value {
17240        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
17241        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
17242            args.get(name).cloned().ok_or_else(|| {
17243                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17244            })?,
17245        )),
17246        ProcedureValue::StepRows(id)
17247        | ProcedureValue::StepRow(id)
17248        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
17249            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
17250        }),
17251        ProcedureValue::Object(fields) => {
17252            let mut out = Vec::with_capacity(fields.len());
17253            for (name, value) in fields {
17254                out.push((name.clone(), eval_return_output(value, args, outputs)?));
17255            }
17256            Ok(ProcedureCallOutput::Object(out))
17257        }
17258        ProcedureValue::Array(values) => {
17259            let mut out = Vec::with_capacity(values.len());
17260            for value in values {
17261                out.push(eval_return_output(value, args, outputs)?);
17262            }
17263            Ok(ProcedureCallOutput::Array(out))
17264        }
17265    }
17266}
17267
17268fn expect_i64(value: crate::Value) -> Result<i64> {
17269    match value {
17270        crate::Value::Int64(value) => Ok(value),
17271        _ => Err(MongrelError::InvalidArgument(
17272            "procedure value must be Int64".into(),
17273        )),
17274    }
17275}
17276
17277fn expect_f64(value: crate::Value) -> Result<f64> {
17278    match value {
17279        crate::Value::Float64(value) => Ok(value),
17280        _ => Err(MongrelError::InvalidArgument(
17281            "procedure value must be Float64".into(),
17282        )),
17283    }
17284}
17285
17286fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
17287    match value {
17288        crate::Value::Bytes(value) => Ok(value),
17289        _ => Err(MongrelError::InvalidArgument(
17290            "procedure value must be Bytes".into(),
17291        )),
17292    }
17293}
17294
17295fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
17296    if schema.columns.iter().any(|c| c.id == column_id) {
17297        Ok(())
17298    } else {
17299        Err(MongrelError::InvalidArgument(format!(
17300            "unknown column id {column_id}"
17301        )))
17302    }
17303}
17304
17305fn trigger_matches_event(
17306    trigger: &StoredTrigger,
17307    event: &WriteEvent,
17308    cat: &Catalog,
17309) -> Result<bool> {
17310    if trigger.event != event.kind {
17311        return Ok(false);
17312    }
17313    let TriggerTarget::Table(target) = &trigger.target else {
17314        return Ok(false);
17315    };
17316    if target != &event.table {
17317        return Ok(false);
17318    }
17319    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
17320        let schema = &cat
17321            .live(target)
17322            .ok_or_else(|| {
17323                MongrelError::InvalidArgument(format!(
17324                    "trigger {:?} references unknown table {target:?}",
17325                    trigger.name
17326                ))
17327            })?
17328            .schema;
17329        let mut watched = Vec::with_capacity(trigger.update_of.len());
17330        for name in &trigger.update_of {
17331            let col = schema.column(name).ok_or_else(|| {
17332                MongrelError::InvalidArgument(format!(
17333                    "trigger {:?} references unknown UPDATE OF column {name:?}",
17334                    trigger.name
17335                ))
17336            })?;
17337            watched.push(col.id);
17338        }
17339        if !event
17340            .changed_columns
17341            .iter()
17342            .any(|column_id| watched.contains(column_id))
17343        {
17344            return Ok(false);
17345        }
17346    }
17347    Ok(true)
17348}
17349
17350fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
17351    let mut ids = std::collections::BTreeSet::new();
17352    if let Some(old) = old {
17353        ids.extend(old.columns.keys().copied());
17354    }
17355    if let Some(new) = new {
17356        ids.extend(new.columns.keys().copied());
17357    }
17358    ids.into_iter()
17359        .filter(|id| {
17360            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
17361        })
17362        .collect()
17363}
17364
17365fn eval_trigger_cells(
17366    cells: &[crate::trigger::TriggerCell],
17367    event: &WriteEvent,
17368    selected: Option<&TriggerRowImage>,
17369) -> Result<Vec<(u16, Value)>> {
17370    cells
17371        .iter()
17372        .map(|cell| {
17373            Ok((
17374                cell.column_id,
17375                eval_trigger_value(&cell.value, event, selected)?,
17376            ))
17377        })
17378        .collect()
17379}
17380
17381fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
17382    match expr {
17383        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
17384            Value::Bool(value) => Ok(value),
17385            Value::Null => Ok(false),
17386            other => Err(MongrelError::InvalidArgument(format!(
17387                "trigger WHEN value must be boolean, got {other:?}"
17388            ))),
17389        },
17390        TriggerExpr::Eq { left, right } => Ok(values_equal(
17391            &eval_trigger_value(left, event, None)?,
17392            &eval_trigger_value(right, event, None)?,
17393        )),
17394        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
17395            &eval_trigger_value(left, event, None)?,
17396            &eval_trigger_value(right, event, None)?,
17397        )),
17398        TriggerExpr::Lt { left, right } => match value_order(
17399            &eval_trigger_value(left, event, None)?,
17400            &eval_trigger_value(right, event, None)?,
17401        ) {
17402            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17403            None => Ok(false),
17404        },
17405        TriggerExpr::Lte { left, right } => match value_order(
17406            &eval_trigger_value(left, event, None)?,
17407            &eval_trigger_value(right, event, None)?,
17408        ) {
17409            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17410            None => Ok(false),
17411        },
17412        TriggerExpr::Gt { left, right } => match value_order(
17413            &eval_trigger_value(left, event, None)?,
17414            &eval_trigger_value(right, event, None)?,
17415        ) {
17416            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17417            None => Ok(false),
17418        },
17419        TriggerExpr::Gte { left, right } => match value_order(
17420            &eval_trigger_value(left, event, None)?,
17421            &eval_trigger_value(right, event, None)?,
17422        ) {
17423            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
17424            None => Ok(false),
17425        },
17426        TriggerExpr::IsNull(value) => Ok(matches!(
17427            eval_trigger_value(value, event, None)?,
17428            Value::Null
17429        )),
17430        TriggerExpr::IsNotNull(value) => Ok(!matches!(
17431            eval_trigger_value(value, event, None)?,
17432            Value::Null
17433        )),
17434        TriggerExpr::And { left, right } => {
17435            if !eval_trigger_expr(left, event)? {
17436                Ok(false)
17437            } else {
17438                Ok(eval_trigger_expr(right, event)?)
17439            }
17440        }
17441        TriggerExpr::Or { left, right } => {
17442            if eval_trigger_expr(left, event)? {
17443                Ok(true)
17444            } else {
17445                Ok(eval_trigger_expr(right, event)?)
17446            }
17447        }
17448        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
17449    }
17450}
17451
17452fn eval_trigger_condition(
17453    condition: &TriggerCondition,
17454    event: &WriteEvent,
17455    selected: &TriggerRowImage,
17456    schema: &Schema,
17457) -> Result<bool> {
17458    match condition {
17459        TriggerCondition::Pk { value } => {
17460            let pk = schema.primary_key().ok_or_else(|| {
17461                MongrelError::InvalidArgument(
17462                    "trigger condition Pk references a table without a primary key".into(),
17463                )
17464            })?;
17465            let lhs = eval_trigger_value(value, event, Some(selected))?;
17466            Ok(values_equal(
17467                &lhs,
17468                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
17469            ))
17470        }
17471        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
17472            selected.columns.get(column_id).unwrap_or(&Value::Null),
17473            &eval_trigger_value(value, event, Some(selected))?,
17474        )),
17475        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
17476            selected.columns.get(column_id).unwrap_or(&Value::Null),
17477            &eval_trigger_value(value, event, Some(selected))?,
17478        )),
17479        TriggerCondition::Lt { column_id, value } => match value_order(
17480            selected.columns.get(column_id).unwrap_or(&Value::Null),
17481            &eval_trigger_value(value, event, Some(selected))?,
17482        ) {
17483            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17484            None => Ok(false),
17485        },
17486        TriggerCondition::Lte { column_id, value } => match value_order(
17487            selected.columns.get(column_id).unwrap_or(&Value::Null),
17488            &eval_trigger_value(value, event, Some(selected))?,
17489        ) {
17490            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17491            None => Ok(false),
17492        },
17493        TriggerCondition::Gt { column_id, value } => match value_order(
17494            selected.columns.get(column_id).unwrap_or(&Value::Null),
17495            &eval_trigger_value(value, event, Some(selected))?,
17496        ) {
17497            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17498            None => Ok(false),
17499        },
17500        TriggerCondition::Gte { column_id, value } => match value_order(
17501            selected.columns.get(column_id).unwrap_or(&Value::Null),
17502            &eval_trigger_value(value, event, Some(selected))?,
17503        ) {
17504            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
17505            None => Ok(false),
17506        },
17507        TriggerCondition::IsNull { column_id } => Ok(matches!(
17508            selected.columns.get(column_id),
17509            None | Some(Value::Null)
17510        )),
17511        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
17512            selected.columns.get(column_id),
17513            None | Some(Value::Null)
17514        )),
17515        TriggerCondition::And { left, right } => {
17516            if !eval_trigger_condition(left, event, selected, schema)? {
17517                Ok(false)
17518            } else {
17519                Ok(eval_trigger_condition(right, event, selected, schema)?)
17520            }
17521        }
17522        TriggerCondition::Or { left, right } => {
17523            if eval_trigger_condition(left, event, selected, schema)? {
17524                Ok(true)
17525            } else {
17526                Ok(eval_trigger_condition(right, event, selected, schema)?)
17527            }
17528        }
17529        TriggerCondition::Not(condition) => {
17530            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
17531        }
17532    }
17533}
17534
17535fn eval_trigger_value(
17536    value: &TriggerValue,
17537    event: &WriteEvent,
17538    selected: Option<&TriggerRowImage>,
17539) -> Result<Value> {
17540    match value {
17541        TriggerValue::Literal(value) => Ok(value.clone()),
17542        TriggerValue::NewColumn(column_id) => event
17543            .new
17544            .as_ref()
17545            .and_then(|row| row.columns.get(column_id))
17546            .cloned()
17547            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
17548        TriggerValue::OldColumn(column_id) => event
17549            .old
17550            .as_ref()
17551            .and_then(|row| row.columns.get(column_id))
17552            .cloned()
17553            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
17554        TriggerValue::SelectedColumn(column_id) => selected
17555            .and_then(|row| row.columns.get(column_id))
17556            .cloned()
17557            .ok_or_else(|| {
17558                MongrelError::InvalidArgument("SELECTED column is not available".into())
17559            }),
17560    }
17561}
17562
17563fn values_equal(left: &Value, right: &Value) -> bool {
17564    match (left, right) {
17565        (Value::Null, Value::Null) => true,
17566        (Value::Bool(a), Value::Bool(b)) => a == b,
17567        (Value::Int64(a), Value::Int64(b)) => a == b,
17568        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
17569        (Value::Bytes(a), Value::Bytes(b)) => a == b,
17570        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => {
17571            let a = a.as_embedding().unwrap();
17572            let b = b.as_embedding().unwrap();
17573            a.len() == b.len()
17574                && a.iter()
17575                    .zip(b.iter())
17576                    .all(|(a, b)| a.to_bits() == b.to_bits())
17577        }
17578        _ => false,
17579    }
17580}
17581
17582fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
17583    match (left, right) {
17584        (Value::Null, _) | (_, Value::Null) => None,
17585        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
17586        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
17587        // Cross-type Int64/Float64 comparison coerces the integer to f64.
17588        // This matches the spec but can lose precision for i64 values above 2^53.
17589        (Value::Int64(a), Value::Float64(b)) => {
17590            let af = *a as f64;
17591            Some(af.total_cmp(b))
17592        }
17593        // Cross-type Int64/Float64 comparison coerces the integer to f64.
17594        // This matches the spec but can lose precision for i64 values above 2^53.
17595        (Value::Float64(a), Value::Int64(b)) => {
17596            let bf = *b as f64;
17597            Some(a.total_cmp(&bf))
17598        }
17599        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
17600        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
17601        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => None,
17602        _ => None,
17603    }
17604}
17605
17606fn trigger_message(value: Value) -> String {
17607    match value {
17608        Value::Null => "NULL".into(),
17609        Value::Bool(value) => value.to_string(),
17610        Value::Int64(value) => value.to_string(),
17611        Value::Float64(value) => value.to_string(),
17612        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
17613        Value::Embedding(value) => format!("{value:?}"),
17614        Value::GeneratedEmbedding(value) => format!("{:?}", value.vector),
17615        Value::Decimal(value) => value.to_string(),
17616        Value::Interval {
17617            months,
17618            days,
17619            nanos,
17620        } => format!("{months}m {days}d {nanos}ns"),
17621        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
17622        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
17623    }
17624}
17625
17626fn validate_trigger_step<'a>(
17627    step: &TriggerStep,
17628    cat: &'a Catalog,
17629    target_schema: &Schema,
17630    event: TriggerEvent,
17631    select_schemas: &mut HashMap<String, &'a Schema>,
17632) -> Result<()> {
17633    match step {
17634        TriggerStep::SetNew { cells } => {
17635            if event == TriggerEvent::Delete {
17636                return Err(MongrelError::InvalidArgument(
17637                    "SetNew trigger step is not valid for DELETE triggers".into(),
17638                ));
17639            }
17640            for cell in cells {
17641                validate_column_id(cell.column_id, target_schema)?;
17642                validate_trigger_value(&cell.value, target_schema, event)?;
17643            }
17644        }
17645        TriggerStep::Insert { table, cells } => {
17646            let schema = trigger_write_schema(cat, table, "insert")?;
17647            for cell in cells {
17648                validate_column_id(cell.column_id, schema)?;
17649                validate_trigger_value(&cell.value, target_schema, event)?;
17650            }
17651        }
17652        TriggerStep::UpdateByPk { table, pk, cells } => {
17653            let schema = trigger_write_schema(cat, table, "update")?;
17654            if schema.primary_key().is_none() {
17655                return Err(MongrelError::InvalidArgument(format!(
17656                    "trigger update_by_pk references table {table:?} without a primary key"
17657                )));
17658            }
17659            validate_trigger_value(pk, target_schema, event)?;
17660            for cell in cells {
17661                validate_column_id(cell.column_id, schema)?;
17662                validate_trigger_value(&cell.value, target_schema, event)?;
17663            }
17664        }
17665        TriggerStep::DeleteByPk { table, pk } => {
17666            let schema = trigger_write_schema(cat, table, "delete")?;
17667            if schema.primary_key().is_none() {
17668                return Err(MongrelError::InvalidArgument(format!(
17669                    "trigger delete_by_pk references table {table:?} without a primary key"
17670                )));
17671            }
17672            validate_trigger_value(pk, target_schema, event)?;
17673        }
17674        TriggerStep::Select {
17675            id,
17676            table,
17677            conditions,
17678        } => {
17679            let schema = trigger_read_schema(cat, table)?;
17680            for condition in conditions {
17681                validate_trigger_condition(condition, schema, target_schema, event)?;
17682            }
17683            if select_schemas.contains_key(id) {
17684                return Err(MongrelError::InvalidArgument(format!(
17685                    "duplicate select id {id:?} in trigger program"
17686                )));
17687            }
17688            select_schemas.insert(id.clone(), schema);
17689        }
17690        TriggerStep::Foreach { id, steps } => {
17691            if !select_schemas.contains_key(id) {
17692                return Err(MongrelError::InvalidArgument(format!(
17693                    "foreach references unknown select id {id:?}"
17694                )));
17695            }
17696            let mut inner_select_schemas = select_schemas.clone();
17697            for step in steps {
17698                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
17699            }
17700        }
17701        TriggerStep::DeleteWhere { table, conditions } => {
17702            let schema = trigger_write_schema(cat, table, "delete")?;
17703            for condition in conditions {
17704                validate_trigger_condition(condition, schema, target_schema, event)?;
17705            }
17706        }
17707        TriggerStep::UpdateWhere {
17708            table,
17709            conditions,
17710            cells,
17711        } => {
17712            let schema = trigger_write_schema(cat, table, "update")?;
17713            for condition in conditions {
17714                validate_trigger_condition(condition, schema, target_schema, event)?;
17715            }
17716            for cell in cells {
17717                validate_column_id(cell.column_id, schema)?;
17718                validate_trigger_value(&cell.value, target_schema, event)?;
17719            }
17720        }
17721        TriggerStep::Raise { message, .. } => {
17722            validate_trigger_value(message, target_schema, event)?
17723        }
17724    }
17725    Ok(())
17726}
17727
17728fn trigger_validation_error(error: MongrelError) -> MongrelError {
17729    match error {
17730        MongrelError::TriggerValidation(_) => error,
17731        MongrelError::InvalidArgument(message)
17732        | MongrelError::Conflict(message)
17733        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
17734        error => error,
17735    }
17736}
17737
17738fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
17739    if let Some(entry) = cat.live(table) {
17740        return Ok(&entry.schema);
17741    }
17742    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
17743        let allowed = match op {
17744            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
17745            "update" | "delete" => entry.capabilities.writable,
17746            _ => false,
17747        };
17748        if !allowed {
17749            return Err(MongrelError::InvalidArgument(format!(
17750                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
17751                entry.module
17752            )));
17753        }
17754        if !entry.capabilities.transaction_safe {
17755            return Err(MongrelError::InvalidArgument(format!(
17756                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
17757                entry.module
17758            )));
17759        }
17760        return Ok(&entry.declared_schema);
17761    }
17762    Err(MongrelError::InvalidArgument(format!(
17763        "trigger references unknown table {table:?}"
17764    )))
17765}
17766
17767fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
17768    if let Some(entry) = cat.live(table) {
17769        return Ok(&entry.schema);
17770    }
17771    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
17772        if entry.capabilities.trigger_safe {
17773            return Ok(&entry.declared_schema);
17774        }
17775        return Err(MongrelError::InvalidArgument(format!(
17776            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
17777            entry.module
17778        )));
17779    }
17780    Err(MongrelError::InvalidArgument(format!(
17781        "trigger references unknown table {table:?}"
17782    )))
17783}
17784
17785fn validate_trigger_condition(
17786    condition: &TriggerCondition,
17787    schema: &Schema,
17788    target_schema: &Schema,
17789    event: TriggerEvent,
17790) -> Result<()> {
17791    match condition {
17792        TriggerCondition::Pk { value } => {
17793            if schema.primary_key().is_none() {
17794                return Err(MongrelError::InvalidArgument(
17795                    "trigger condition Pk references a table without a primary key".into(),
17796                ));
17797            }
17798            validate_trigger_value(value, target_schema, event)
17799        }
17800        TriggerCondition::Eq { column_id, value }
17801        | TriggerCondition::NotEq { column_id, value }
17802        | TriggerCondition::Lt { column_id, value }
17803        | TriggerCondition::Lte { column_id, value }
17804        | TriggerCondition::Gt { column_id, value }
17805        | TriggerCondition::Gte { column_id, value } => {
17806            validate_column_id(*column_id, schema)?;
17807            validate_trigger_value(value, target_schema, event)
17808        }
17809        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
17810            validate_column_id(*column_id, schema)
17811        }
17812        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
17813            validate_trigger_condition(left, schema, target_schema, event)?;
17814            validate_trigger_condition(right, schema, target_schema, event)
17815        }
17816        TriggerCondition::Not(condition) => {
17817            validate_trigger_condition(condition, schema, target_schema, event)
17818        }
17819    }
17820}
17821
17822fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
17823    match expr {
17824        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
17825            validate_trigger_value(value, schema, event)
17826        }
17827        TriggerExpr::Eq { left, right }
17828        | TriggerExpr::NotEq { left, right }
17829        | TriggerExpr::Lt { left, right }
17830        | TriggerExpr::Lte { left, right }
17831        | TriggerExpr::Gt { left, right }
17832        | TriggerExpr::Gte { left, right } => {
17833            validate_trigger_value(left, schema, event)?;
17834            validate_trigger_value(right, schema, event)
17835        }
17836        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
17837            validate_trigger_expr(left, schema, event)?;
17838            validate_trigger_expr(right, schema, event)
17839        }
17840        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
17841    }
17842}
17843
17844fn validate_trigger_value(
17845    value: &TriggerValue,
17846    schema: &Schema,
17847    event: TriggerEvent,
17848) -> Result<()> {
17849    match value {
17850        TriggerValue::Literal(_) => Ok(()),
17851        TriggerValue::NewColumn(id) => {
17852            if event == TriggerEvent::Delete {
17853                return Err(MongrelError::InvalidArgument(
17854                    "DELETE triggers cannot reference NEW".into(),
17855                ));
17856            }
17857            validate_column_id(*id, schema)
17858        }
17859        TriggerValue::OldColumn(id) => {
17860            if event == TriggerEvent::Insert {
17861                return Err(MongrelError::InvalidArgument(
17862                    "INSERT triggers cannot reference OLD".into(),
17863                ));
17864            }
17865            validate_column_id(*id, schema)
17866        }
17867        // SELECTED column references are only meaningful inside a foreach loop.
17868        // Strict loop-scope validation is deferred to runtime; the executor raises
17869        // an error if a selected row is not available.
17870        TriggerValue::SelectedColumn(_) => Ok(()),
17871    }
17872}
17873
17874/// Bound on the retained tail of the per-open commit-timestamp ledger
17875/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
17876/// needs recent epochs, so the map keeps the newest commits and drops the
17877/// rest (a miss falls back to a fresh-begin HLC at the caller).
17878const COMMIT_TS_LEDGER_CAP: usize = 10_000;
17879
17880/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
17881/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
17882/// and the `Op::CommitTimestamp` record written ahead of it carries the
17883/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
17884/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
17885/// micros only (same constraint [`crate::commit_log`] documents for replayed
17886/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
17887fn commit_ts_ledger_from_recovery(
17888    records: &[crate::wal::Record],
17889) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
17890    use crate::wal::Op;
17891    let mut commits = HashMap::new();
17892    let mut timestamps = HashMap::new();
17893    for record in records {
17894        match &record.op {
17895            Op::TxnCommit { epoch, .. } => {
17896                commits.insert(record.txn_id, *epoch);
17897            }
17898            Op::CommitTimestamp { unix_nanos } => {
17899                timestamps.insert(record.txn_id, *unix_nanos);
17900            }
17901            _ => {}
17902        }
17903    }
17904    let mut ledger = std::collections::BTreeMap::new();
17905    for (txn_id, epoch) in commits {
17906        let Some(unix_nanos) = timestamps.get(&txn_id) else {
17907            continue;
17908        };
17909        ledger.insert(
17910            epoch,
17911            mongreldb_types::hlc::HlcTimestamp {
17912                physical_micros: unix_nanos / 1_000,
17913                logical: 0,
17914                node_tiebreaker: 0,
17915            },
17916        );
17917    }
17918    while ledger.len() > COMMIT_TS_LEDGER_CAP {
17919        ledger.pop_first();
17920    }
17921    ledger
17922}
17923
17924/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
17925/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
17926/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
17927/// catalog. This pass closes that window by reconstructing missing entries
17928/// (and marking committed drops) before tables are mounted.
17929fn recover_ddl_from_wal(
17930    root: &Path,
17931    durable_root: Option<&crate::durable_file::DurableRoot>,
17932    target_catalog: &mut Catalog,
17933    meta_dek: Option<&[u8; META_DEK_LEN]>,
17934    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
17935    apply: bool,
17936    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
17937) -> Result<()> {
17938    use crate::wal::SharedWal;
17939    let records = match durable_root {
17940        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
17941        None => SharedWal::replay_with_dek(root, wal_dek)?,
17942    };
17943    recover_ddl_from_records(
17944        root,
17945        durable_root,
17946        target_catalog,
17947        meta_dek,
17948        apply,
17949        table_roots,
17950        &records,
17951    )
17952}
17953
17954fn recover_ddl_from_records(
17955    root: &Path,
17956    durable_root: Option<&crate::durable_file::DurableRoot>,
17957    target_catalog: &mut Catalog,
17958    meta_dek: Option<&[u8; META_DEK_LEN]>,
17959    apply: bool,
17960    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
17961    records: &[crate::wal::Record],
17962) -> Result<()> {
17963    use crate::wal::{DdlOp, Op};
17964
17965    let original_catalog = target_catalog.clone();
17966    let mut recovered_catalog = original_catalog.clone();
17967    let cat = &mut recovered_catalog;
17968    let mut created_table_ids = HashSet::<u64>::new();
17969    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
17970
17971    let mut committed: HashMap<u64, u64> = HashMap::new();
17972    for r in records {
17973        if let Op::TxnCommit { epoch: ce, .. } = r.op {
17974            committed.insert(r.txn_id, ce);
17975        }
17976    }
17977    let catalog_snapshot_txns = records
17978        .iter()
17979        .filter_map(|record| {
17980            (committed.contains_key(&record.txn_id)
17981                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
17982            .then_some(record.txn_id)
17983        })
17984        .collect::<HashSet<_>>();
17985
17986    let mut changed = false;
17987    let mut applied_catalog_epoch = cat.db_epoch;
17988    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
17989    for r in records.iter().cloned() {
17990        let Some(&ce) = committed.get(&r.txn_id) else {
17991            continue;
17992        };
17993        let txn_id = r.txn_id;
17994        match r.op {
17995            Op::Ddl(DdlOp::CreateTable {
17996                table_id,
17997                ref name,
17998                ref schema_json,
17999            }) => {
18000                if cat.tables.iter().any(|t| t.table_id == table_id) {
18001                    continue;
18002                }
18003                let schema = DdlOp::decode_schema(schema_json)?;
18004                validate_recovered_schema(&schema)?;
18005                created_table_ids.insert(table_id);
18006                cat.tables.push(CatalogEntry {
18007                    table_id,
18008                    name: name.clone(),
18009                    schema,
18010                    state: TableState::Live,
18011                    created_epoch: ce,
18012                });
18013                cat.next_table_id =
18014                    cat.next_table_id
18015                        .max(table_id.checked_add(1).ok_or_else(|| {
18016                            MongrelError::Full("table id namespace exhausted".into())
18017                        })?);
18018                changed = true;
18019            }
18020            Op::Ddl(DdlOp::CreateBuildingTable {
18021                table_id,
18022                ref build_name,
18023                ref intended_name,
18024                ref query_id,
18025                created_at_unix_nanos,
18026                ref schema_json,
18027            }) => {
18028                if cat.tables.iter().any(|table| table.table_id == table_id) {
18029                    continue;
18030                }
18031                let schema = DdlOp::decode_schema(schema_json)?;
18032                validate_recovered_schema(&schema)?;
18033                created_table_ids.insert(table_id);
18034                cat.tables.push(CatalogEntry {
18035                    table_id,
18036                    name: build_name.clone(),
18037                    schema,
18038                    state: TableState::Building {
18039                        intended_name: intended_name.clone(),
18040                        query_id: query_id.clone(),
18041                        created_at_unix_nanos,
18042                        replaces_table_id: None,
18043                    },
18044                    created_epoch: ce,
18045                });
18046                cat.next_table_id =
18047                    cat.next_table_id
18048                        .max(table_id.checked_add(1).ok_or_else(|| {
18049                            MongrelError::Full("table id namespace exhausted".into())
18050                        })?);
18051                changed = true;
18052            }
18053            Op::Ddl(DdlOp::CreateRebuildingTable {
18054                table_id,
18055                ref build_name,
18056                ref intended_name,
18057                ref query_id,
18058                created_at_unix_nanos,
18059                replaces_table_id,
18060                ref schema_json,
18061            }) => {
18062                if cat.tables.iter().any(|table| table.table_id == table_id) {
18063                    continue;
18064                }
18065                let schema = DdlOp::decode_schema(schema_json)?;
18066                validate_recovered_schema(&schema)?;
18067                created_table_ids.insert(table_id);
18068                cat.tables.push(CatalogEntry {
18069                    table_id,
18070                    name: build_name.clone(),
18071                    schema,
18072                    state: TableState::Building {
18073                        intended_name: intended_name.clone(),
18074                        query_id: query_id.clone(),
18075                        created_at_unix_nanos,
18076                        replaces_table_id: Some(replaces_table_id),
18077                    },
18078                    created_epoch: ce,
18079                });
18080                cat.next_table_id =
18081                    cat.next_table_id
18082                        .max(table_id.checked_add(1).ok_or_else(|| {
18083                            MongrelError::Full("table id namespace exhausted".into())
18084                        })?);
18085                changed = true;
18086            }
18087            Op::Ddl(DdlOp::DropTable { table_id }) => {
18088                let mut dropped_name = None;
18089                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18090                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18091                        dropped_name = Some(entry.name.clone());
18092                        entry.state = TableState::Dropped { at_epoch: ce };
18093                        changed = true;
18094                    }
18095                }
18096                if let Some(name) = dropped_name {
18097                    let before = cat.materialized_views.len();
18098                    cat.materialized_views
18099                        .retain(|definition| definition.name != name);
18100                    changed |= before != cat.materialized_views.len();
18101                    cat.security.rls_tables.retain(|table| table != &name);
18102                    cat.security.policies.retain(|policy| policy.table != name);
18103                    cat.security.masks.retain(|mask| mask.table != name);
18104                    for role in &mut cat.roles {
18105                        role.permissions
18106                            .retain(|permission| permission_table(permission) != Some(&name));
18107                    }
18108                    if !catalog_snapshot_txns.contains(&txn_id) {
18109                        advance_security_version(cat)?;
18110                    }
18111                }
18112            }
18113            Op::Ddl(DdlOp::PublishBuildingTable {
18114                table_id,
18115                ref new_name,
18116            }) => {
18117                if let Some(entry) = cat
18118                    .tables
18119                    .iter_mut()
18120                    .find(|table| table.table_id == table_id)
18121                {
18122                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
18123                        entry.name = new_name.clone();
18124                        entry.state = TableState::Live;
18125                        changed = true;
18126                    }
18127                }
18128            }
18129            Op::Ddl(DdlOp::ReplaceBuildingTable {
18130                table_id,
18131                replaced_table_id,
18132                ref new_name,
18133            }) => {
18134                changed |=
18135                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
18136            }
18137            Op::Ddl(DdlOp::RenameTable {
18138                table_id,
18139                ref new_name,
18140            }) => {
18141                let mut old_name = None;
18142                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18143                    if entry.name != *new_name {
18144                        old_name = Some(entry.name.clone());
18145                        entry.name = new_name.clone();
18146                        changed = true;
18147                    }
18148                }
18149                if let Some(old_name) = old_name {
18150                    if let Some(definition) = cat
18151                        .materialized_views
18152                        .iter_mut()
18153                        .find(|definition| definition.name == old_name)
18154                    {
18155                        definition.name = new_name.clone();
18156                    }
18157                    for table in &mut cat.security.rls_tables {
18158                        if *table == old_name {
18159                            *table = new_name.clone();
18160                        }
18161                    }
18162                    for policy in &mut cat.security.policies {
18163                        if policy.table == old_name {
18164                            policy.table = new_name.clone();
18165                        }
18166                    }
18167                    for mask in &mut cat.security.masks {
18168                        if mask.table == old_name {
18169                            mask.table = new_name.clone();
18170                        }
18171                    }
18172                    for role in &mut cat.roles {
18173                        for permission in &mut role.permissions {
18174                            rename_permission_table(permission, &old_name, new_name);
18175                        }
18176                    }
18177                    if !catalog_snapshot_txns.contains(&txn_id) {
18178                        advance_security_version(cat)?;
18179                    }
18180                }
18181                // If the entry is absent, its CreateTable was already
18182                // checkpointed carrying the post-rename name, so there is
18183                // nothing to apply — a no-op, not an error.
18184            }
18185            Op::Ddl(DdlOp::AlterTable {
18186                table_id,
18187                ref column_json,
18188            }) => {
18189                let column = DdlOp::decode_column(column_json)?;
18190                let mut renamed = None;
18191                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18192                    renamed = entry
18193                        .schema
18194                        .columns
18195                        .iter()
18196                        .find(|existing| existing.id == column.id && existing.name != column.name)
18197                        .map(|existing| {
18198                            (
18199                                entry.name.clone(),
18200                                existing.name.clone(),
18201                                column.name.clone(),
18202                            )
18203                        });
18204                    if apply_recovered_column_def(&mut entry.schema, column)? {
18205                        validate_recovered_schema(&entry.schema)?;
18206                        changed = true;
18207                    }
18208                }
18209                if let Some((table, old_name, new_name)) = renamed {
18210                    for role in &mut cat.roles {
18211                        for permission in &mut role.permissions {
18212                            rename_permission_column(permission, &table, &old_name, &new_name);
18213                        }
18214                    }
18215                    if !catalog_snapshot_txns.contains(&txn_id) {
18216                        advance_security_version(cat)?;
18217                    }
18218                }
18219            }
18220            Op::Ddl(DdlOp::SetTtl {
18221                table_id,
18222                ref policy_json,
18223            }) => {
18224                let policy = DdlOp::decode_ttl(policy_json)?;
18225                let entry = cat
18226                    .tables
18227                    .iter()
18228                    .find(|entry| entry.table_id == table_id)
18229                    .ok_or_else(|| {
18230                        MongrelError::Schema(format!(
18231                            "recovered TTL references unknown table id {table_id}"
18232                        ))
18233                    })?;
18234                if let Some(policy) = policy {
18235                    let valid = entry
18236                        .schema
18237                        .columns
18238                        .iter()
18239                        .find(|column| column.id == policy.column_id)
18240                        .is_some_and(|column| {
18241                            column.ty == TypeId::TimestampNanos
18242                                && policy.duration_nanos > 0
18243                                && policy.duration_nanos <= i64::MAX as u64
18244                        });
18245                    if !valid {
18246                        return Err(MongrelError::Schema(format!(
18247                            "invalid recovered TTL policy for table id {table_id}"
18248                        )));
18249                    }
18250                }
18251                ttl_updates.insert(table_id, (policy, ce));
18252            }
18253            Op::Ddl(DdlOp::SetMaterializedView {
18254                ref name,
18255                ref definition_json,
18256            }) => {
18257                let definition = DdlOp::decode_materialized_view(definition_json)?;
18258                if definition.name != *name {
18259                    return Err(MongrelError::Schema(format!(
18260                        "materialized view WAL name mismatch: {name:?}"
18261                    )));
18262                }
18263                if cat.live(name).is_some() {
18264                    if let Some(existing) = cat
18265                        .materialized_views
18266                        .iter_mut()
18267                        .find(|existing| existing.name == *name)
18268                    {
18269                        if *existing != definition {
18270                            *existing = definition;
18271                            changed = true;
18272                        }
18273                    } else {
18274                        cat.materialized_views.push(definition);
18275                        changed = true;
18276                    }
18277                }
18278            }
18279            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
18280                let security = DdlOp::decode_security(security_json)?;
18281                validate_security_catalog(cat, &security)?;
18282                if cat.security != security {
18283                    cat.security = security;
18284                    if !catalog_snapshot_txns.contains(&txn_id) {
18285                        advance_security_version(cat)?;
18286                    }
18287                    changed = true;
18288                }
18289            }
18290            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
18291                let target = match key.as_str() {
18292                    "user_version" => &mut cat.user_version,
18293                    "application_id" => &mut cat.application_id,
18294                    _ => {
18295                        return Err(MongrelError::InvalidArgument(format!(
18296                            "unsupported recovered SQL pragma {key:?}"
18297                        )))
18298                    }
18299                };
18300                if *target != Some(value) {
18301                    *target = Some(value);
18302                    cat.db_epoch = cat.db_epoch.max(ce);
18303                    changed = true;
18304                }
18305            }
18306            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
18307                if ce <= applied_catalog_epoch {
18308                    continue;
18309                }
18310                let snapshot = DdlOp::decode_catalog(catalog_json)?;
18311                if snapshot.db_epoch != ce {
18312                    return Err(MongrelError::Schema(format!(
18313                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
18314                        snapshot.db_epoch
18315                    )));
18316                }
18317                validate_recovered_catalog(&snapshot)?;
18318                validate_catalog_transition(cat, &snapshot)?;
18319                *cat = snapshot;
18320                applied_catalog_epoch = ce;
18321                changed = true;
18322            }
18323            _ => {}
18324        }
18325    }
18326
18327    if cat.db_epoch < max_committed_epoch {
18328        cat.db_epoch = max_committed_epoch;
18329        changed = true;
18330    }
18331    changed |= repair_catalog_allocator_counters(cat)?;
18332
18333    validate_recovered_catalog(cat)?;
18334    let storage_reconciliation = validate_recovered_storage_plan(
18335        root,
18336        durable_root,
18337        cat,
18338        &created_table_ids,
18339        &ttl_updates,
18340        meta_dek,
18341    )?;
18342
18343    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
18344    if apply && (changed || needs_storage_apply) {
18345        for table_id in storage_reconciliation {
18346            let entry = cat
18347                .tables
18348                .iter()
18349                .find(|entry| entry.table_id == table_id)
18350                .ok_or_else(|| MongrelError::CorruptWal {
18351                    offset: table_id,
18352                    reason: "recovery storage plan lost its catalog table".into(),
18353                })?;
18354            ensure_recovered_table_storage(
18355                table_roots
18356                    .and_then(|roots| roots.get(&table_id))
18357                    .map(Arc::as_ref),
18358                durable_root,
18359                &root.join(TABLES_DIR).join(table_id.to_string()),
18360                table_id,
18361                &entry.schema,
18362                meta_dek,
18363            )?;
18364        }
18365        for (table_id, (policy, ttl_epoch)) in ttl_updates {
18366            let Some(entry) = cat.tables.iter().find(|entry| {
18367                entry.table_id == table_id
18368                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
18369            }) else {
18370                continue;
18371            };
18372            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
18373            {
18374                root.try_clone()?
18375            } else if let Some(root) = durable_root {
18376                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
18377            } else {
18378                crate::durable_file::DurableRoot::open(
18379                    root.join(TABLES_DIR).join(table_id.to_string()),
18380                )?
18381            };
18382            let table_dir = table_root.io_path()?;
18383            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
18384            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
18385                manifest.ttl = policy;
18386                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
18387                manifest.schema_id = entry.schema.schema_id;
18388                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18389            }
18390        }
18391        if changed {
18392            match durable_root {
18393                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
18394                None => catalog::write_atomic(root, cat, meta_dek)?,
18395            }
18396        }
18397    }
18398    *target_catalog = recovered_catalog;
18399    Ok(())
18400}
18401
18402fn ensure_recovered_table_storage(
18403    pinned_table: Option<&crate::durable_file::DurableRoot>,
18404    durable_root: Option<&crate::durable_file::DurableRoot>,
18405    fallback_table_dir: &Path,
18406    table_id: u64,
18407    schema: &Schema,
18408    meta_dek: Option<&[u8; META_DEK_LEN]>,
18409) -> Result<()> {
18410    let table_root = if let Some(root) = pinned_table {
18411        root.try_clone()?
18412    } else if let Some(root) = durable_root {
18413        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
18414        match root.open_directory(&relative) {
18415            Ok(table) => table,
18416            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
18417                root.create_directory_all_pinned(relative)?
18418            }
18419            Err(error) => return Err(error.into()),
18420        }
18421    } else {
18422        crate::durable_file::create_directory_all(fallback_table_dir)?;
18423        crate::durable_file::DurableRoot::open(fallback_table_dir)?
18424    };
18425    let table_dir = table_root.io_path()?;
18426    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
18427        Ok(manifest) => {
18428            if manifest.table_id != table_id {
18429                return Err(MongrelError::Conflict(format!(
18430                    "recovered table directory id mismatch: expected {table_id}, found {}",
18431                    manifest.table_id
18432                )));
18433            }
18434            Some(manifest)
18435        }
18436        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
18437        Err(error) => return Err(error),
18438    };
18439
18440    table_root.create_directory_all(crate::engine::WAL_DIR)?;
18441    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
18442    crate::engine::write_schema(&table_dir, schema)?;
18443
18444    if let Some(mut manifest) = existing_manifest.take() {
18445        if manifest.schema_id != schema.schema_id {
18446            manifest.schema_id = schema.schema_id;
18447            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18448        }
18449    } else {
18450        // The DB-wide meta DEK is also the per-table manifest meta DEK.
18451        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
18452        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18453    }
18454    Ok(())
18455}
18456
18457fn validate_recovered_schema(schema: &Schema) -> Result<()> {
18458    schema.validate_auto_increment()?;
18459    schema.validate_defaults()?;
18460    schema.validate_ai()?;
18461    let mut column_ids = HashSet::new();
18462    let mut column_names = HashSet::new();
18463    for column in &schema.columns {
18464        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
18465            return Err(MongrelError::Schema(
18466                "recovered schema contains duplicate columns".into(),
18467            ));
18468        }
18469        match &column.ty {
18470            TypeId::Decimal128 { precision, scale }
18471                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
18472            {
18473                return Err(MongrelError::Schema(format!(
18474                    "column {:?} has invalid decimal precision or scale",
18475                    column.name
18476                )));
18477            }
18478            TypeId::Enum { variants }
18479                if variants.is_empty()
18480                    || variants.iter().any(String::is_empty)
18481                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
18482            {
18483                return Err(MongrelError::Schema(format!(
18484                    "column {:?} has invalid enum variants",
18485                    column.name
18486                )));
18487            }
18488            _ => {}
18489        }
18490    }
18491    let mut index_names = HashSet::new();
18492    for index in &schema.indexes {
18493        index.validate_options()?;
18494        if index.name.is_empty()
18495            || !index_names.insert(index.name.as_str())
18496            || schema
18497                .columns
18498                .iter()
18499                .all(|column| column.id != index.column_id)
18500        {
18501            return Err(MongrelError::Schema(format!(
18502                "recovered index {:?} references missing column {}",
18503                index.name, index.column_id
18504            )));
18505        }
18506    }
18507    let mut colocated = HashSet::new();
18508    for group in &schema.colocation {
18509        if group.is_empty()
18510            || group.iter().any(|id| !column_ids.contains(id))
18511            || group.iter().any(|id| !colocated.insert(*id))
18512        {
18513            return Err(MongrelError::Schema(
18514                "recovered schema contains invalid column co-location groups".into(),
18515            ));
18516        }
18517    }
18518
18519    let mut constraint_ids = HashSet::new();
18520    let mut constraint_names = HashSet::<String>::new();
18521    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
18522        if name.is_empty()
18523            || !constraint_ids.insert(id)
18524            || !constraint_names.insert(name.to_owned())
18525        {
18526            return Err(MongrelError::Schema(
18527                "recovered schema contains duplicate or empty constraint identities".into(),
18528            ));
18529        }
18530        Ok(())
18531    };
18532    for unique in &schema.constraints.uniques {
18533        validate_constraint_identity(unique.id, &unique.name)?;
18534        if unique.columns.is_empty()
18535            || unique.columns.iter().any(|id| !column_ids.contains(id))
18536            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
18537        {
18538            return Err(MongrelError::Schema(format!(
18539                "unique constraint {:?} has invalid columns",
18540                unique.name
18541            )));
18542        }
18543    }
18544    for foreign_key in &schema.constraints.foreign_keys {
18545        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
18546        if foreign_key.ref_table.is_empty()
18547            || foreign_key.columns.is_empty()
18548            || foreign_key.columns.len() != foreign_key.ref_columns.len()
18549            || foreign_key
18550                .columns
18551                .iter()
18552                .any(|id| !column_ids.contains(id))
18553            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
18554            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
18555                != foreign_key.ref_columns.len()
18556        {
18557            return Err(MongrelError::Schema(format!(
18558                "foreign key {:?} has invalid columns",
18559                foreign_key.name
18560            )));
18561        }
18562        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
18563            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
18564            && foreign_key.columns.iter().any(|id| {
18565                schema
18566                    .columns
18567                    .iter()
18568                    .find(|column| column.id == *id)
18569                    .is_none_or(|column| {
18570                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
18571                    })
18572            })
18573        {
18574            return Err(MongrelError::Schema(format!(
18575                "foreign key {:?} uses SET NULL on a non-nullable column",
18576                foreign_key.name
18577            )));
18578        }
18579    }
18580    for check in &schema.constraints.checks {
18581        validate_constraint_identity(check.id, &check.name)?;
18582        check.expr.validate()?;
18583        validate_check_columns(&check.expr, &column_ids)?;
18584    }
18585    Ok(())
18586}
18587
18588fn validate_check_columns(
18589    expression: &crate::constraint::CheckExpr,
18590    column_ids: &HashSet<u16>,
18591) -> Result<()> {
18592    use crate::constraint::CheckExpr;
18593    match expression {
18594        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
18595            if column_ids.contains(id) {
18596                Ok(())
18597            } else {
18598                Err(MongrelError::Schema(format!(
18599                    "check constraint references unknown column {id}"
18600                )))
18601            }
18602        }
18603        CheckExpr::Regex { col, .. } => {
18604            if column_ids.contains(col) {
18605                Ok(())
18606            } else {
18607                Err(MongrelError::Schema(format!(
18608                    "check constraint references unknown column {col}"
18609                )))
18610            }
18611        }
18612        CheckExpr::Add(left, right)
18613        | CheckExpr::Sub(left, right)
18614        | CheckExpr::Mul(left, right)
18615        | CheckExpr::Div(left, right)
18616        | CheckExpr::Mod(left, right)
18617        | CheckExpr::Eq(left, right)
18618        | CheckExpr::Ne(left, right)
18619        | CheckExpr::Lt(left, right)
18620        | CheckExpr::Le(left, right)
18621        | CheckExpr::Gt(left, right)
18622        | CheckExpr::Ge(left, right)
18623        | CheckExpr::And(left, right)
18624        | CheckExpr::Or(left, right) => {
18625            validate_check_columns(left, column_ids)?;
18626            validate_check_columns(right, column_ids)
18627        }
18628        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
18629        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
18630    }
18631}
18632
18633fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
18634    for (name, prior, candidate) in [
18635        ("db_epoch", current.db_epoch, next.db_epoch),
18636        ("next_table_id", current.next_table_id, next.next_table_id),
18637        (
18638            "next_segment_no",
18639            current.next_segment_no,
18640            next.next_segment_no,
18641        ),
18642        ("next_user_id", current.next_user_id, next.next_user_id),
18643        (
18644            "security_version",
18645            current.security_version,
18646            next.security_version,
18647        ),
18648    ] {
18649        if candidate < prior {
18650            return Err(MongrelError::Schema(format!(
18651                "catalog snapshot rolls back {name} from {prior} to {candidate}"
18652            )));
18653        }
18654    }
18655    for prior in &current.tables {
18656        let Some(candidate) = next
18657            .tables
18658            .iter()
18659            .find(|entry| entry.table_id == prior.table_id)
18660        else {
18661            return Err(MongrelError::Schema(format!(
18662                "catalog snapshot removes table identity {}",
18663                prior.table_id
18664            )));
18665        };
18666        if candidate.created_epoch != prior.created_epoch
18667            || candidate.schema.schema_id < prior.schema.schema_id
18668            || matches!(prior.state, TableState::Dropped { .. })
18669                && !matches!(candidate.state, TableState::Dropped { .. })
18670        {
18671            return Err(MongrelError::Schema(format!(
18672                "catalog snapshot rolls back table identity {}",
18673                prior.table_id
18674            )));
18675        }
18676    }
18677    for prior in &current.users {
18678        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
18679            if candidate.username != prior.username
18680                || candidate.created_epoch != prior.created_epoch
18681            {
18682                return Err(MongrelError::Schema(format!(
18683                    "catalog snapshot reuses user identity {}",
18684                    prior.id
18685                )));
18686            }
18687        }
18688    }
18689    Ok(())
18690}
18691
18692fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
18693    let mut table_ids = HashSet::new();
18694    let mut active_names = HashSet::new();
18695    let mut max_table_id = None::<u64>;
18696    for entry in &catalog.tables {
18697        if !table_ids.insert(entry.table_id) {
18698            return Err(MongrelError::Schema(format!(
18699                "catalog contains duplicate table id {}",
18700                entry.table_id
18701            )));
18702        }
18703        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
18704        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
18705            return Err(MongrelError::Schema(format!(
18706                "catalog table {} has invalid name or creation epoch",
18707                entry.table_id
18708            )));
18709        }
18710        validate_recovered_schema(&entry.schema)?;
18711        match &entry.state {
18712            TableState::Live => {
18713                if !active_names.insert(entry.name.as_str()) {
18714                    return Err(MongrelError::Schema(format!(
18715                        "catalog contains duplicate active table name {:?}",
18716                        entry.name
18717                    )));
18718                }
18719            }
18720            TableState::Dropped { at_epoch } => {
18721                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
18722                    return Err(MongrelError::Schema(format!(
18723                        "catalog table {} has invalid drop epoch {at_epoch}",
18724                        entry.table_id
18725                    )));
18726                }
18727            }
18728            TableState::Building {
18729                intended_name,
18730                query_id,
18731                replaces_table_id,
18732                ..
18733            } => {
18734                if intended_name.is_empty() || query_id.is_empty() {
18735                    return Err(MongrelError::Schema(format!(
18736                        "building table {} has empty identity fields",
18737                        entry.table_id
18738                    )));
18739                }
18740                if !active_names.insert(entry.name.as_str()) {
18741                    return Err(MongrelError::Schema(format!(
18742                        "catalog contains duplicate active/building table name {:?}",
18743                        entry.name
18744                    )));
18745                }
18746                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
18747                    return Err(MongrelError::Schema(
18748                        "building table cannot replace itself".into(),
18749                    ));
18750                }
18751            }
18752        }
18753    }
18754    if let Some(maximum) = max_table_id {
18755        let required = maximum
18756            .checked_add(1)
18757            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
18758        if catalog.next_table_id < required {
18759            return Err(MongrelError::Schema(format!(
18760                "catalog next_table_id {} precedes required {required}",
18761                catalog.next_table_id
18762            )));
18763        }
18764    }
18765    for entry in &catalog.tables {
18766        if let TableState::Building {
18767            replaces_table_id: Some(replaced),
18768            ..
18769        } = entry.state
18770        {
18771            if !table_ids.contains(&replaced) {
18772                return Err(MongrelError::Schema(format!(
18773                    "building table {} replaces unknown table {replaced}",
18774                    entry.table_id
18775                )));
18776            }
18777        }
18778    }
18779    for entry in &catalog.tables {
18780        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18781            validate_foreign_key_targets(catalog, &entry.schema)?;
18782        }
18783    }
18784
18785    let mut external_names = HashSet::new();
18786    for entry in &catalog.external_tables {
18787        entry.validate()?;
18788        validate_recovered_schema(&entry.declared_schema)?;
18789        if !entry.declared_schema.constraints.is_empty() {
18790            return Err(MongrelError::Schema(format!(
18791                "external table {:?} cannot carry engine-enforced constraints",
18792                entry.name
18793            )));
18794        }
18795        if entry.created_epoch > catalog.db_epoch
18796            || !external_names.insert(entry.name.as_str())
18797            || active_names.contains(entry.name.as_str())
18798        {
18799            return Err(MongrelError::Schema(format!(
18800                "invalid or duplicate external table {:?}",
18801                entry.name
18802            )));
18803        }
18804    }
18805
18806    let mut procedure_names = HashSet::new();
18807    for entry in &catalog.procedures {
18808        entry.procedure.validate()?;
18809        if entry.procedure.created_epoch > entry.procedure.updated_epoch
18810            || entry.procedure.updated_epoch > catalog.db_epoch
18811            || !procedure_names.insert(entry.procedure.name.as_str())
18812        {
18813            return Err(MongrelError::Schema(format!(
18814                "invalid or duplicate procedure {:?}",
18815                entry.procedure.name
18816            )));
18817        }
18818        validate_recovered_procedure_references(catalog, &entry.procedure)?;
18819    }
18820
18821    let mut trigger_names = HashSet::new();
18822    for entry in &catalog.triggers {
18823        entry.trigger.validate()?;
18824        if entry.trigger.created_epoch > entry.trigger.updated_epoch
18825            || entry.trigger.updated_epoch > catalog.db_epoch
18826            || !trigger_names.insert(entry.trigger.name.as_str())
18827        {
18828            return Err(MongrelError::Schema(format!(
18829                "invalid or duplicate trigger {:?}",
18830                entry.trigger.name
18831            )));
18832        }
18833        validate_recovered_trigger_references(catalog, &entry.trigger)?;
18834    }
18835
18836    let mut views = HashSet::new();
18837    for view in &catalog.materialized_views {
18838        let target = catalog.live(&view.name).ok_or_else(|| {
18839            MongrelError::Schema(format!(
18840                "materialized view {:?} has no live table",
18841                view.name
18842            ))
18843        })?;
18844        if view.name.is_empty()
18845            || view.query.trim().is_empty()
18846            || view.last_refresh_epoch > catalog.db_epoch
18847            || !views.insert(view.name.as_str())
18848        {
18849            return Err(MongrelError::Schema(format!(
18850                "materialized view {:?} has no unique live table",
18851                view.name
18852            )));
18853        }
18854        if let Some(incremental) = &view.incremental {
18855            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
18856                MongrelError::Schema(format!(
18857                    "materialized view {:?} references missing source {:?}",
18858                    view.name, incremental.source_table
18859                ))
18860            })?;
18861            if source.table_id != incremental.source_table_id
18862                || source
18863                    .schema
18864                    .columns
18865                    .iter()
18866                    .all(|column| column.id != incremental.group_column)
18867            {
18868                return Err(MongrelError::Schema(format!(
18869                    "materialized view {:?} has invalid incremental source",
18870                    view.name
18871                )));
18872            }
18873            let target_ids = target
18874                .schema
18875                .columns
18876                .iter()
18877                .map(|column| column.id)
18878                .collect::<HashSet<_>>();
18879            let mut output_ids = HashSet::new();
18880            let count_outputs = incremental
18881                .outputs
18882                .iter()
18883                .filter(|output| {
18884                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
18885                })
18886                .count();
18887            if incremental.checkpoint_event_id.is_empty()
18888                || !target_ids.contains(&incremental.group_output_column)
18889                || !target_ids.contains(&incremental.count_output_column)
18890                || incremental.outputs.is_empty()
18891                || count_outputs != 1
18892                || incremental.outputs.iter().any(|output| {
18893                    !target_ids.contains(&output.output_column)
18894                        || output.output_column == incremental.group_output_column
18895                        || !output_ids.insert(output.output_column)
18896                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
18897                            && output.output_column != incremental.count_output_column
18898                        || match output.kind {
18899                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
18900                                source
18901                                    .schema
18902                                    .columns
18903                                    .iter()
18904                                    .all(|column| column.id != source_column)
18905                            }
18906                            crate::catalog::IncrementalAggregateKind::Count => false,
18907                        }
18908                })
18909            {
18910                return Err(MongrelError::Schema(format!(
18911                    "materialized view {:?} has invalid incremental outputs",
18912                    view.name
18913                )));
18914            }
18915        }
18916    }
18917
18918    validate_security_catalog(catalog, &catalog.security)?;
18919    validate_recovered_auth_catalog(catalog)?;
18920    Ok(())
18921}
18922
18923fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
18924    let mut changed = false;
18925    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
18926        let required = maximum
18927            .checked_add(1)
18928            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
18929        if catalog.next_table_id < required {
18930            catalog.next_table_id = required;
18931            changed = true;
18932        }
18933    }
18934    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
18935        let required = maximum
18936            .checked_add(1)
18937            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
18938        if catalog.next_user_id < required {
18939            catalog.next_user_id = required;
18940            changed = true;
18941        }
18942    }
18943    Ok(changed)
18944}
18945
18946fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
18947    for foreign_key in &schema.constraints.foreign_keys {
18948        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
18949            MongrelError::Schema(format!(
18950                "foreign key {:?} references unknown live table {:?}",
18951                foreign_key.name, foreign_key.ref_table
18952            ))
18953        })?;
18954        let referenced_unique = parent
18955            .schema
18956            .constraints
18957            .uniques
18958            .iter()
18959            .any(|unique| unique.columns == foreign_key.ref_columns)
18960            || foreign_key.ref_columns.len() == 1
18961                && parent
18962                    .schema
18963                    .primary_key()
18964                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
18965        if !referenced_unique {
18966            return Err(MongrelError::Schema(format!(
18967                "foreign key {:?} does not reference a unique key",
18968                foreign_key.name
18969            )));
18970        }
18971        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
18972            let local = schema.columns.iter().find(|column| column.id == *local_id);
18973            let referenced = parent
18974                .schema
18975                .columns
18976                .iter()
18977                .find(|column| column.id == *parent_id);
18978            if local
18979                .zip(referenced)
18980                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
18981            {
18982                return Err(MongrelError::Schema(format!(
18983                    "foreign key {:?} has missing or incompatible columns",
18984                    foreign_key.name
18985                )));
18986            }
18987        }
18988    }
18989    Ok(())
18990}
18991
18992fn validate_recovered_procedure_references(
18993    catalog: &Catalog,
18994    procedure: &StoredProcedure,
18995) -> Result<()> {
18996    for step in &procedure.body.steps {
18997        let Some(table_name) = step.table() else {
18998            continue;
18999        };
19000        let schema = &catalog
19001            .live(table_name)
19002            .ok_or_else(|| {
19003                MongrelError::Schema(format!(
19004                    "procedure {:?} references unknown table {table_name:?}",
19005                    procedure.name
19006                ))
19007            })?
19008            .schema;
19009        match step {
19010            ProcedureStep::NativeQuery {
19011                conditions,
19012                projection,
19013                ..
19014            } => {
19015                for condition in conditions {
19016                    validate_condition_columns(condition, schema)?;
19017                }
19018                for id in projection.iter().flatten() {
19019                    validate_column_id(*id, schema)?;
19020                }
19021            }
19022            ProcedureStep::Put { cells, .. } => {
19023                for cell in cells {
19024                    validate_column_id(cell.column_id, schema)?;
19025                }
19026            }
19027            ProcedureStep::Upsert {
19028                cells,
19029                update_cells,
19030                ..
19031            } => {
19032                for cell in cells.iter().chain(update_cells.iter().flatten()) {
19033                    validate_column_id(cell.column_id, schema)?;
19034                }
19035            }
19036            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
19037                return Err(MongrelError::Schema(format!(
19038                    "procedure {:?} deletes by primary key on table without one",
19039                    procedure.name
19040                )));
19041            }
19042            ProcedureStep::DeleteByPk { .. }
19043            | ProcedureStep::DeleteRows { .. }
19044            | ProcedureStep::SqlQuery { .. } => {}
19045        }
19046    }
19047    Ok(())
19048}
19049
19050fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
19051    let target_schema = match &trigger.target {
19052        TriggerTarget::Table(name) => catalog
19053            .live(name)
19054            .ok_or_else(|| {
19055                MongrelError::Schema(format!(
19056                    "trigger {:?} references unknown table {name:?}",
19057                    trigger.name
19058                ))
19059            })?
19060            .schema
19061            .clone(),
19062        TriggerTarget::View(_) => Schema {
19063            columns: trigger.target_columns.clone(),
19064            ..Schema::default()
19065        },
19066    };
19067    for column in &trigger.update_of {
19068        if target_schema.column(column).is_none() {
19069            return Err(MongrelError::Schema(format!(
19070                "trigger {:?} references unknown UPDATE OF column {column:?}",
19071                trigger.name
19072            )));
19073        }
19074    }
19075    if let Some(expr) = &trigger.when {
19076        validate_trigger_expr(expr, &target_schema, trigger.event)?;
19077    }
19078    let mut selects = HashMap::new();
19079    for step in &trigger.program.steps {
19080        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
19081            return Err(MongrelError::Schema(
19082                "SetNew is only valid in BEFORE triggers".into(),
19083            ));
19084        }
19085        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
19086    }
19087    Ok(())
19088}
19089
19090fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
19091    let mut role_names = HashSet::new();
19092    for role in &catalog.roles {
19093        if role.name.is_empty()
19094            || role.created_epoch > catalog.db_epoch
19095            || !role_names.insert(role.name.as_str())
19096        {
19097            return Err(MongrelError::Schema(format!(
19098                "invalid or duplicate role {:?}",
19099                role.name
19100            )));
19101        }
19102        for permission in &role.permissions {
19103            if let Some(table) = permission_table(permission) {
19104                let schema = catalog
19105                    .live(table)
19106                    .map(|entry| &entry.schema)
19107                    .or_else(|| {
19108                        catalog
19109                            .external_tables
19110                            .iter()
19111                            .find(|entry| entry.name == table)
19112                            .map(|entry| &entry.declared_schema)
19113                    })
19114                    .ok_or_else(|| {
19115                        MongrelError::Schema(format!(
19116                            "role {:?} references unknown table {table:?}",
19117                            role.name
19118                        ))
19119                    })?;
19120                let columns = match permission {
19121                    crate::auth::Permission::SelectColumns { columns, .. }
19122                    | crate::auth::Permission::InsertColumns { columns, .. }
19123                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
19124                    _ => None,
19125                };
19126                if columns.is_some_and(|columns| {
19127                    columns.is_empty()
19128                        || columns.iter().any(|column| schema.column(column).is_none())
19129                }) {
19130                    return Err(MongrelError::Schema(format!(
19131                        "role {:?} contains invalid column permissions",
19132                        role.name
19133                    )));
19134                }
19135            }
19136        }
19137    }
19138    let mut user_ids = HashSet::new();
19139    let mut usernames = HashSet::new();
19140    let mut maximum_user_id = 0;
19141    for user in &catalog.users {
19142        maximum_user_id = maximum_user_id.max(user.id);
19143        if user.id == 0
19144            || user.username.is_empty()
19145            || user.password_hash.is_empty()
19146            || user.created_epoch > catalog.db_epoch
19147            || !user_ids.insert(user.id)
19148            || !usernames.insert(user.username.as_str())
19149            || user
19150                .roles
19151                .iter()
19152                .any(|role| !role_names.contains(role.as_str()))
19153        {
19154            return Err(MongrelError::Schema(format!(
19155                "invalid or duplicate user {:?}",
19156                user.username
19157            )));
19158        }
19159    }
19160    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
19161        return Err(MongrelError::Schema(
19162            "catalog next_user_id does not advance beyond existing user ids".into(),
19163        ));
19164    }
19165    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
19166        return Err(MongrelError::Schema(
19167            "authenticated catalog has no administrator".into(),
19168        ));
19169    }
19170    Ok(())
19171}
19172
19173fn validate_recovered_storage_plan(
19174    root: &Path,
19175    durable_root: Option<&crate::durable_file::DurableRoot>,
19176    catalog: &Catalog,
19177    created_table_ids: &HashSet<u64>,
19178    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
19179    meta_dek: Option<&[u8; META_DEK_LEN]>,
19180) -> Result<Vec<u64>> {
19181    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
19182    let mut reconcile = Vec::new();
19183    for entry in &catalog.tables {
19184        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19185            continue;
19186        }
19187        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19188        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
19189        let table_exists = match durable_root {
19190            Some(root) => match root.open_directory(&relative_dir) {
19191                Ok(_) => true,
19192                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19193                Err(error) => return Err(error.into()),
19194            },
19195            None => table_dir.is_dir(),
19196        };
19197        if !table_exists {
19198            if created_table_ids.contains(&entry.table_id) {
19199                reconcile.push(entry.table_id);
19200                continue;
19201            }
19202            return Err(MongrelError::NotFound(format!(
19203                "catalog table {} storage is missing",
19204                entry.table_id
19205            )));
19206        }
19207        let manifest_result = match durable_root {
19208            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
19209            None => crate::manifest::read(&table_dir, meta_dek),
19210        };
19211        let manifest = match manifest_result {
19212            Ok(manifest) => manifest,
19213            Err(MongrelError::Io(error))
19214                if created_table_ids.contains(&entry.table_id)
19215                    && error.kind() == std::io::ErrorKind::NotFound =>
19216            {
19217                reconcile.push(entry.table_id);
19218                continue;
19219            }
19220            Err(error) => return Err(error),
19221        };
19222        if manifest.table_id != entry.table_id {
19223            return Err(MongrelError::Conflict(format!(
19224                "catalog table {} storage identity mismatch",
19225                entry.table_id
19226            )));
19227        }
19228        let schema_result = match durable_root {
19229            Some(root) => root
19230                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
19231                .map_err(MongrelError::from),
19232            None => crate::durable_file::open_regular_nofollow(
19233                &table_dir.join(crate::engine::SCHEMA_FILENAME),
19234            ),
19235        };
19236        let file = match schema_result {
19237            Ok(file) => file,
19238            Err(MongrelError::Io(error))
19239                if created_table_ids.contains(&entry.table_id)
19240                    && error.kind() == std::io::ErrorKind::NotFound =>
19241            {
19242                reconcile.push(entry.table_id);
19243                continue;
19244            }
19245            Err(error) => return Err(error),
19246        };
19247        let length = file.metadata()?.len();
19248        if length > MAX_SCHEMA_BYTES {
19249            return Err(MongrelError::ResourceLimitExceeded {
19250                resource: "recovered schema bytes",
19251                requested: usize::try_from(length).unwrap_or(usize::MAX),
19252                limit: MAX_SCHEMA_BYTES as usize,
19253            });
19254        }
19255        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
19256            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
19257        if manifest.schema_id != entry.schema.schema_id
19258            || crate::wal::DdlOp::encode_schema(&disk_schema)?
19259                != crate::wal::DdlOp::encode_schema(&entry.schema)?
19260        {
19261            reconcile.push(entry.table_id);
19262        }
19263    }
19264    for table_id in ttl_updates.keys() {
19265        if !catalog.tables.iter().any(|entry| {
19266            entry.table_id == *table_id
19267                && matches!(entry.state, TableState::Live | TableState::Building { .. })
19268        }) {
19269            continue;
19270        }
19271        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
19272        let table_exists = match durable_root {
19273            Some(root) => match root.open_directory(&relative_dir) {
19274                Ok(_) => true,
19275                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19276                Err(error) => return Err(error.into()),
19277            },
19278            None => root.join(&relative_dir).is_dir(),
19279        };
19280        if !table_exists && !created_table_ids.contains(table_id) {
19281            return Err(MongrelError::NotFound(format!(
19282                "TTL recovery table {table_id} storage is missing"
19283            )));
19284        }
19285    }
19286    reconcile.sort_unstable();
19287    reconcile.dedup();
19288    Ok(reconcile)
19289}
19290
19291fn validate_catalog_table_storage(
19292    root: &crate::durable_file::DurableRoot,
19293    catalog: &Catalog,
19294    meta_dek: Option<&[u8; META_DEK_LEN]>,
19295) -> Result<()> {
19296    for entry in &catalog.tables {
19297        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19298            continue;
19299        }
19300        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19301        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
19302        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
19303            return Err(MongrelError::Conflict(format!(
19304                "catalog table {} storage identity mismatch",
19305                entry.table_id
19306            )));
19307        }
19308        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
19309    }
19310    Ok(())
19311}
19312
19313fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
19314    match schema.columns.iter_mut().find(|c| c.id == column.id) {
19315        Some(existing) if *existing == column => Ok(false),
19316        Some(existing) => {
19317            *existing = column;
19318            schema.schema_id = schema
19319                .schema_id
19320                .checked_add(1)
19321                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19322            Ok(true)
19323        }
19324        None => {
19325            schema.columns.push(column);
19326            schema.schema_id = schema
19327                .schema_id
19328                .checked_add(1)
19329                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19330            Ok(true)
19331        }
19332    }
19333}
19334
19335fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
19336    use crate::auth::Permission;
19337    match permission {
19338        Permission::Select { table }
19339        | Permission::Insert { table }
19340        | Permission::Update { table }
19341        | Permission::Delete { table }
19342        | Permission::SelectColumns { table, .. }
19343        | Permission::InsertColumns { table, .. }
19344        | Permission::UpdateColumns { table, .. } => Some(table),
19345        Permission::All | Permission::Ddl | Permission::Admin => None,
19346    }
19347}
19348
19349fn apply_rebuilding_publish(
19350    catalog: &mut Catalog,
19351    table_id: u64,
19352    replaced_table_id: u64,
19353    new_name: &str,
19354    epoch: u64,
19355) -> Result<bool> {
19356    let already_published = catalog.tables.iter().any(|entry| {
19357        entry.table_id == table_id
19358            && entry.name == new_name
19359            && matches!(entry.state, TableState::Live)
19360    }) && catalog.tables.iter().any(|entry| {
19361        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
19362    });
19363    if already_published {
19364        return Ok(false);
19365    }
19366    let schema = catalog
19367        .tables
19368        .iter()
19369        .find(|entry| entry.table_id == table_id)
19370        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
19371        .schema
19372        .clone();
19373    let replaced = catalog
19374        .tables
19375        .iter_mut()
19376        .find(|entry| entry.table_id == replaced_table_id)
19377        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
19378    replaced.state = TableState::Dropped { at_epoch: epoch };
19379    let replacement = catalog
19380        .tables
19381        .iter_mut()
19382        .find(|entry| entry.table_id == table_id)
19383        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
19384    replacement.name = new_name.to_string();
19385    replacement.state = TableState::Live;
19386
19387    for role in &mut catalog.roles {
19388        role.permissions.retain_mut(|permission| {
19389            retain_rebuilt_permission_columns(permission, new_name, &schema)
19390        });
19391    }
19392    for definition in &mut catalog.materialized_views {
19393        if let Some(incremental) = definition.incremental.as_mut() {
19394            if incremental.source_table == new_name
19395                && incremental.source_table_id == replaced_table_id
19396            {
19397                incremental.source_table_id = table_id;
19398            }
19399        }
19400    }
19401    advance_security_version(catalog)?;
19402    Ok(true)
19403}
19404
19405fn retain_rebuilt_permission_columns(
19406    permission: &mut crate::auth::Permission,
19407    target_table: &str,
19408    schema: &Schema,
19409) -> bool {
19410    use crate::auth::Permission;
19411    let columns = match permission {
19412        Permission::SelectColumns { table, columns }
19413        | Permission::InsertColumns { table, columns }
19414        | Permission::UpdateColumns { table, columns }
19415            if table == target_table =>
19416        {
19417            Some(columns)
19418        }
19419        _ => None,
19420    };
19421    if let Some(columns) = columns {
19422        columns.retain(|column| schema.column(column).is_some());
19423        !columns.is_empty()
19424    } else {
19425        true
19426    }
19427}
19428
19429fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
19430    use crate::auth::Permission;
19431    let table = match permission {
19432        Permission::Select { table }
19433        | Permission::Insert { table }
19434        | Permission::Update { table }
19435        | Permission::Delete { table }
19436        | Permission::SelectColumns { table, .. }
19437        | Permission::InsertColumns { table, .. }
19438        | Permission::UpdateColumns { table, .. } => Some(table),
19439        Permission::All | Permission::Ddl | Permission::Admin => None,
19440    };
19441    if let Some(table) = table.filter(|table| table.as_str() == old) {
19442        *table = new.to_string();
19443    }
19444}
19445
19446fn rename_permission_column(
19447    permission: &mut crate::auth::Permission,
19448    target_table: &str,
19449    old: &str,
19450    new: &str,
19451) {
19452    use crate::auth::Permission;
19453    let columns = match permission {
19454        Permission::SelectColumns { table, columns }
19455        | Permission::InsertColumns { table, columns }
19456        | Permission::UpdateColumns { table, columns }
19457            if table == target_table =>
19458        {
19459            Some(columns)
19460        }
19461        _ => None,
19462    };
19463    if let Some(column) = columns
19464        .into_iter()
19465        .flatten()
19466        .find(|column| column.as_str() == old)
19467    {
19468        *column = new.to_string();
19469    }
19470}
19471
19472pub(crate) fn validate_security_catalog(
19473    catalog: &Catalog,
19474    security: &crate::security::SecurityCatalog,
19475) -> Result<()> {
19476    let mut policy_names = HashSet::new();
19477    for table in &security.rls_tables {
19478        if catalog.live(table).is_none() {
19479            return Err(MongrelError::NotFound(format!(
19480                "RLS table {table:?} not found"
19481            )));
19482        }
19483    }
19484    for policy in &security.policies {
19485        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
19486            return Err(MongrelError::InvalidArgument(format!(
19487                "duplicate policy {:?} on {:?}",
19488                policy.name, policy.table
19489            )));
19490        }
19491        let schema = &catalog
19492            .live(&policy.table)
19493            .ok_or_else(|| {
19494                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
19495            })?
19496            .schema;
19497        if let Some(expression) = &policy.using {
19498            validate_security_expression(expression, schema)?;
19499        }
19500        if let Some(expression) = &policy.with_check {
19501            validate_security_expression(expression, schema)?;
19502        }
19503    }
19504    let mut mask_names = HashSet::new();
19505    for mask in &security.masks {
19506        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
19507            return Err(MongrelError::InvalidArgument(format!(
19508                "duplicate mask {:?} on {:?}",
19509                mask.name, mask.table
19510            )));
19511        }
19512        let column = catalog
19513            .live(&mask.table)
19514            .and_then(|entry| {
19515                entry
19516                    .schema
19517                    .columns
19518                    .iter()
19519                    .find(|column| column.id == mask.column)
19520            })
19521            .ok_or_else(|| {
19522                MongrelError::NotFound(format!(
19523                    "mask column {} on {:?} not found",
19524                    mask.column, mask.table
19525                ))
19526            })?;
19527        if matches!(
19528            mask.strategy,
19529            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
19530        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
19531        {
19532            return Err(MongrelError::InvalidArgument(format!(
19533                "mask {:?} requires a string/bytes column",
19534                mask.name
19535            )));
19536        }
19537    }
19538    Ok(())
19539}
19540
19541fn validate_security_expression(
19542    expression: &crate::security::SecurityExpr,
19543    schema: &Schema,
19544) -> Result<()> {
19545    use crate::security::SecurityExpr;
19546    match expression {
19547        SecurityExpr::True => Ok(()),
19548        SecurityExpr::ColumnEqCurrentUser { column }
19549        | SecurityExpr::ColumnEqValue { column, .. } => {
19550            if schema
19551                .columns
19552                .iter()
19553                .any(|candidate| candidate.id == *column)
19554            {
19555                Ok(())
19556            } else {
19557                Err(MongrelError::InvalidArgument(format!(
19558                    "security expression references unknown column id {column}"
19559                )))
19560            }
19561        }
19562        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
19563            validate_security_expression(left, schema)?;
19564            validate_security_expression(right, schema)
19565        }
19566        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
19567    }
19568}
19569
19570/// Remove canonical numeric table directories that no catalog generation owns.
19571fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
19572    let referenced = cat
19573        .tables
19574        .iter()
19575        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
19576        .map(|entry| entry.table_id)
19577        .collect::<HashSet<_>>();
19578    let tables_dir = root.join(TABLES_DIR);
19579    let entries = match std::fs::read_dir(&tables_dir) {
19580        Ok(entries) => entries,
19581        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
19582        Err(error) => return Err(error.into()),
19583    };
19584    for entry in entries {
19585        let entry = entry?;
19586        if !entry.file_type()?.is_dir() {
19587            continue;
19588        }
19589        let file_name = entry.file_name();
19590        let Some(name) = file_name.to_str() else {
19591            continue;
19592        };
19593        let Ok(table_id) = name.parse::<u64>() else {
19594            continue;
19595        };
19596        if name != table_id.to_string() {
19597            continue;
19598        }
19599        if !referenced.contains(&table_id) {
19600            crate::durable_file::remove_directory_all(&entry.path())?;
19601        }
19602    }
19603    Ok(())
19604}
19605
19606/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
19607/// #14). These dirs hold pending uniform-epoch runs from large transactions
19608/// that were aborted or crashed before commit. On open, all such dirs are safe
19609/// to remove because committed txns moved their runs to `_runs/` at publish.
19610fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
19611    for entry in &cat.tables {
19612        let txn_dir = root
19613            .join(TABLES_DIR)
19614            .join(entry.table_id.to_string())
19615            .join("_txn");
19616        if txn_dir.exists() {
19617            let _ = std::fs::remove_dir_all(&txn_dir);
19618        }
19619    }
19620}
19621
19622#[cfg(test)]
19623mod write_permission_tests {
19624    use super::*;
19625    use crate::txn::Staged;
19626
19627    struct NoopExternalBridge;
19628
19629    impl ExternalTriggerBridge for NoopExternalBridge {
19630        fn apply_trigger_external_write(
19631            &self,
19632            _entry: &ExternalTableEntry,
19633            base_state: Vec<u8>,
19634            _op: ExternalTriggerWrite,
19635        ) -> Result<ExternalTriggerWriteResult> {
19636            Ok(ExternalTriggerWriteResult::new(base_state))
19637        }
19638    }
19639
19640    fn assert_txn_namespace_full<T>(result: Result<T>) {
19641        assert!(matches!(result, Err(MongrelError::Full(_))));
19642    }
19643
19644    #[test]
19645    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
19646        let directory = tempfile::tempdir().unwrap();
19647        let database = Database::create(directory.path()).unwrap();
19648        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
19649        *database.next_txn_id.lock() = generation << 32;
19650        let before = crate::wal::SharedWal::replay(directory.path())
19651            .unwrap()
19652            .len();
19653        let bridge = NoopExternalBridge;
19654
19655        assert_txn_namespace_full(database.begin().commit());
19656        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
19657        assert_txn_namespace_full(
19658            database
19659                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
19660                .commit(),
19661        );
19662        assert_txn_namespace_full(
19663            database
19664                .begin_with_external_trigger_bridge(&bridge)
19665                .commit(),
19666        );
19667        assert_txn_namespace_full(
19668            database
19669                .begin_with_external_trigger_bridge_as(&bridge, None)
19670                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
19671        );
19672
19673        assert_eq!(
19674            crate::wal::SharedWal::replay(directory.path())
19675                .unwrap()
19676                .len(),
19677            before
19678        );
19679        drop(database);
19680        Database::open(directory.path()).unwrap();
19681    }
19682
19683    #[test]
19684    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
19685        let directory = tempfile::tempdir().unwrap();
19686        let table_dir = directory.path().join("7");
19687        crate::durable_file::create_directory_all(&table_dir).unwrap();
19688        let original_schema = test_schema();
19689        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
19690        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
19691        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
19692        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
19693        let original_bytes = std::fs::read(&schema_path).unwrap();
19694
19695        let mut replacement_schema = original_schema;
19696        replacement_schema.schema_id += 1;
19697        assert!(matches!(
19698            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
19699            Err(MongrelError::Conflict(_))
19700        ));
19701
19702        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
19703        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
19704        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
19705        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
19706    }
19707
19708    #[test]
19709    fn catalog_table_missing_storage_fails_without_recreating_it() {
19710        let directory = tempfile::tempdir().unwrap();
19711        let table_dir = {
19712            let database = Database::create(directory.path()).unwrap();
19713            database.create_table("docs", test_schema()).unwrap();
19714            directory
19715                .path()
19716                .join(TABLES_DIR)
19717                .join(database.table_id("docs").unwrap().to_string())
19718        };
19719        std::fs::remove_dir_all(&table_dir).unwrap();
19720
19721        assert!(matches!(
19722            Database::open(directory.path()),
19723            Err(MongrelError::NotFound(_))
19724        ));
19725        assert!(!table_dir.exists());
19726    }
19727
19728    #[test]
19729    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
19730        let directory = tempfile::tempdir().unwrap();
19731        let database = std::sync::Arc::new(
19732            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
19733        );
19734        database.create_user("alice", "old-password").unwrap();
19735        let old_identity = database.user_identity("alice").unwrap();
19736        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
19737        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
19738        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
19739        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
19740
19741        std::thread::scope(|scope| {
19742            let authenticate = {
19743                let database = std::sync::Arc::clone(&database);
19744                scope.spawn(move || {
19745                    database.authenticate_principal_inner("alice", "old-password", || {
19746                        verified_tx.send(()).unwrap();
19747                        resume_rx.recv().unwrap();
19748                    })
19749                })
19750            };
19751            verified_rx.recv().unwrap();
19752            let mutate = {
19753                let database = std::sync::Arc::clone(&database);
19754                scope.spawn(move || {
19755                    mutation_started_tx.send(()).unwrap();
19756                    database.drop_user("alice").unwrap();
19757                    database.create_user("alice", "new-password").unwrap();
19758                    mutation_done_tx.send(()).unwrap();
19759                })
19760            };
19761            mutation_started_rx.recv().unwrap();
19762            assert!(mutation_done_rx
19763                .recv_timeout(std::time::Duration::from_millis(50))
19764                .is_err());
19765            resume_tx.send(()).unwrap();
19766            let principal = authenticate.join().unwrap().unwrap().unwrap();
19767            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
19768            mutate.join().unwrap();
19769        });
19770
19771        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
19772        assert!(database
19773            .authenticate_principal("alice", "old-password")
19774            .unwrap()
19775            .is_none());
19776        assert!(database
19777            .authenticate_principal("alice", "new-password")
19778            .unwrap()
19779            .is_some());
19780    }
19781
19782    #[test]
19783    fn homogeneous_batch_summarizes_to_one_permission_decision() {
19784        let staging = (0..10_050)
19785            .map(|_| {
19786                (
19787                    7,
19788                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
19789                )
19790            })
19791            .collect::<Vec<_>>();
19792
19793        let needs = summarize_write_permissions(&staging);
19794        let table = needs.get(&7).unwrap();
19795        assert_eq!(needs.len(), 1);
19796        assert!(table.insert);
19797        assert_eq!(table.insert_columns, [1, 2]);
19798        assert!(!table.update);
19799        assert!(!table.delete);
19800        assert!(!table.truncate);
19801    }
19802
19803    #[test]
19804    fn mixed_writes_union_columns_and_preserve_empty_operations() {
19805        let staging = vec![
19806            (7, Staged::Put(vec![(2, Value::Int64(2))])),
19807            (7, Staged::Put(vec![(1, Value::Int64(1))])),
19808            (
19809                7,
19810                Staged::Update {
19811                    row_id: RowId(1),
19812                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
19813                    changed_columns: vec![2],
19814                },
19815            ),
19816            (7, Staged::Delete(RowId(2))),
19817            (8, Staged::Truncate),
19818        ];
19819
19820        let needs = summarize_write_permissions(&staging);
19821        let table = needs.get(&7).unwrap();
19822        assert_eq!(table.insert_columns, [1, 2]);
19823        assert!(table.update);
19824        assert_eq!(table.update_columns, [2]);
19825        assert!(table.delete);
19826        assert!(needs.get(&8).unwrap().truncate);
19827    }
19828
19829    #[test]
19830    fn final_permission_decisions_do_not_scale_with_rows() {
19831        let credentialless_dir = tempfile::tempdir().unwrap();
19832        let credentialless = Database::create(credentialless_dir.path()).unwrap();
19833        credentialless.create_table("docs", test_schema()).unwrap();
19834        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
19835        credentialless
19836            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
19837            .unwrap();
19838        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
19839
19840        let authenticated_dir = tempfile::tempdir().unwrap();
19841        let authenticated =
19842            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
19843                .unwrap();
19844        authenticated.create_table("docs", test_schema()).unwrap();
19845        let admin = authenticated.resolve_principal("admin").unwrap();
19846        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
19847        authenticated
19848            .validate_write_permissions(
19849                &puts(authenticated.table_id("docs").unwrap()),
19850                Some(&admin),
19851                None,
19852            )
19853            .unwrap();
19854        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
19855    }
19856
19857    #[test]
19858    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
19859        let dir = tempfile::tempdir().unwrap();
19860        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
19861        db.create_table("docs", test_schema()).unwrap();
19862        let admin = db.resolve_principal("admin").unwrap();
19863        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
19864
19865        let mut transaction = db.begin_as(Some(admin));
19866        transaction
19867            .delete_batch("docs", (0..100).map(RowId).collect())
19868            .unwrap();
19869        transaction.commit().unwrap();
19870
19871        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
19872    }
19873
19874    #[test]
19875    fn truncate_validation_checks_admin_once_for_all_tables() {
19876        let dir = tempfile::tempdir().unwrap();
19877        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
19878        db.create_table("first", test_schema()).unwrap();
19879        db.create_table("second", test_schema()).unwrap();
19880        let admin = db.resolve_principal("admin").unwrap();
19881        let staging = vec![
19882            (db.table_id("first").unwrap(), Staged::Truncate),
19883            (db.table_id("second").unwrap(), Staged::Truncate),
19884        ];
19885
19886        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
19887        db.validate_write_permissions(&staging, Some(&admin), None)
19888            .unwrap();
19889        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
19890    }
19891
19892    #[test]
19893    fn one_table_commit_batches_structural_work() {
19894        let dir = tempfile::tempdir().unwrap();
19895        let db = Database::create(dir.path()).unwrap();
19896        db.create_table("docs", test_schema()).unwrap();
19897        let table_id = db.table_id("docs").unwrap();
19898
19899        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
19900        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
19901        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
19902        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
19903        db.transaction(|transaction| {
19904            for id in 0..100 {
19905                transaction.put("docs", vec![(1, Value::Int64(id))])?;
19906            }
19907            Ok(())
19908        })
19909        .unwrap();
19910
19911        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
19912        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
19913        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
19914        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
19915
19916        let puts = crate::wal::SharedWal::replay(dir.path())
19917            .unwrap()
19918            .into_iter()
19919            .filter_map(|record| match record.op {
19920                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
19921                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
19922                        .unwrap()
19923                        .len(),
19924                ),
19925                _ => None,
19926            })
19927            .collect::<Vec<_>>();
19928        assert_eq!(puts, [100]);
19929
19930        let row_ids = db
19931            .table("docs")
19932            .unwrap()
19933            .lock()
19934            .visible_rows(db.snapshot().0)
19935            .unwrap()
19936            .into_iter()
19937            .take(2)
19938            .map(|row| row.row_id)
19939            .collect::<Vec<_>>();
19940        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
19941        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
19942        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
19943        db.transaction(|transaction| {
19944            for row_id in row_ids {
19945                transaction.delete("docs", row_id)?;
19946            }
19947            Ok(())
19948        })
19949        .unwrap();
19950        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
19951        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
19952        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
19953
19954        let deletes = crate::wal::SharedWal::replay(dir.path())
19955            .unwrap()
19956            .into_iter()
19957            .filter_map(|record| match record.op {
19958                crate::wal::Op::Delete {
19959                    table_id: id,
19960                    row_ids,
19961                } if id == table_id => Some(row_ids.len()),
19962                _ => None,
19963            })
19964            .collect::<Vec<_>>();
19965        assert_eq!(deletes, [2]);
19966    }
19967
19968    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
19969        (0..10_050)
19970            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
19971            .collect()
19972    }
19973
19974    fn test_schema() -> Schema {
19975        Schema {
19976            columns: vec![ColumnDef {
19977                id: 1,
19978                name: "id".into(),
19979                ty: TypeId::Int64,
19980                flags: crate::schema::ColumnFlags::empty()
19981                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
19982                default_value: None,
19983                embedding_source: None,
19984            }],
19985            ..Schema::default()
19986        }
19987    }
19988}
19989
19990#[cfg(test)]
19991mod cdc_bounds_tests {
19992    use super::*;
19993
19994    #[test]
19995    fn retained_byte_limit_rejects_without_allocating_payload() {
19996        let mut retained = 0;
19997        let error = charge_cdc_bytes(
19998            &mut retained,
19999            CDC_MAX_RETAINED_BYTES.saturating_add(1),
20000            "CDC retained bytes",
20001        )
20002        .unwrap_err();
20003        assert!(matches!(
20004            error,
20005            MongrelError::ResourceLimitExceeded {
20006                resource: "CDC retained bytes",
20007                ..
20008            }
20009        ));
20010    }
20011
20012    #[test]
20013    fn row_json_estimate_accounts_for_byte_array_expansion() {
20014        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
20015            .with_column(1, Value::Bytes(vec![0; 1024]));
20016        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
20017    }
20018}
20019
20020#[cfg(test)]
20021mod generation_metrics_tests {
20022    use super::*;
20023    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20024
20025    #[test]
20026    fn legacy_cow_fallback_is_measured() {
20027        let dir = tempfile::tempdir().unwrap();
20028        let table = Table::create(
20029            dir.path(),
20030            Schema {
20031                columns: vec![ColumnDef {
20032                    id: 1,
20033                    name: "id".into(),
20034                    ty: TypeId::Int64,
20035                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20036                    default_value: None,
20037                    embedding_source: None,
20038                }],
20039                ..Schema::default()
20040            },
20041            1,
20042        )
20043        .unwrap();
20044        let handle = TableHandle::from_table(table);
20045        let held = match &handle.inner {
20046            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
20047            TableHandleInner::Direct(_) => unreachable!(),
20048        };
20049
20050        handle.lock().set_sync_byte_threshold(1);
20051
20052        let stats = handle.generation_stats();
20053        assert_eq!(stats.cow_clone_count, 1);
20054        assert!(stats.estimated_cow_clone_bytes > 0);
20055        drop(held);
20056    }
20057}
20058
20059#[cfg(test)]
20060mod trigger_engine_tests {
20061    use super::*;
20062
20063    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
20064        WriteEvent {
20065            table: "test".into(),
20066            kind: TriggerEvent::Insert,
20067            new: Some(TriggerRowImage {
20068                columns: new_cells.iter().cloned().collect(),
20069            }),
20070            old: Some(TriggerRowImage {
20071                columns: old_cells.iter().cloned().collect(),
20072            }),
20073            changed_columns: Vec::new(),
20074            op_indices: Vec::new(),
20075            put_idx: None,
20076            trigger_stack: Vec::new(),
20077        }
20078    }
20079
20080    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
20081        WriteEvent {
20082            table: "test".into(),
20083            kind: TriggerEvent::Insert,
20084            new: Some(TriggerRowImage {
20085                columns: new_cells.iter().cloned().collect(),
20086            }),
20087            old: None,
20088            changed_columns: Vec::new(),
20089            op_indices: Vec::new(),
20090            put_idx: None,
20091            trigger_stack: Vec::new(),
20092        }
20093    }
20094
20095    #[test]
20096    fn value_order_int64_vs_float64() {
20097        assert_eq!(
20098            value_order(&Value::Int64(5), &Value::Float64(5.0)),
20099            Some(std::cmp::Ordering::Equal)
20100        );
20101        assert_eq!(
20102            value_order(&Value::Int64(5), &Value::Float64(3.0)),
20103            Some(std::cmp::Ordering::Greater)
20104        );
20105        assert_eq!(
20106            value_order(&Value::Int64(2), &Value::Float64(3.0)),
20107            Some(std::cmp::Ordering::Less)
20108        );
20109    }
20110
20111    #[test]
20112    fn value_order_null_returns_none() {
20113        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
20114        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
20115        assert_eq!(value_order(&Value::Null, &Value::Null), None);
20116    }
20117
20118    #[test]
20119    fn value_order_cross_group_returns_none() {
20120        assert_eq!(
20121            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
20122            None
20123        );
20124        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
20125        assert_eq!(
20126            value_order(
20127                &Value::Embedding(vec![1.0, 2.0]),
20128                &Value::Embedding(vec![1.0, 2.0])
20129            ),
20130            None
20131        );
20132    }
20133
20134    #[test]
20135    fn eval_trigger_expr_ranges_and_booleans() {
20136        let expr = TriggerExpr::And {
20137            left: Box::new(TriggerExpr::Gt {
20138                left: TriggerValue::NewColumn(1),
20139                right: TriggerValue::Literal(Value::Int64(0)),
20140            }),
20141            right: Box::new(TriggerExpr::Lte {
20142                left: TriggerValue::NewColumn(1),
20143                right: TriggerValue::Literal(Value::Int64(100)),
20144            }),
20145        };
20146        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
20147        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
20148        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
20149
20150        let or_expr = TriggerExpr::Or {
20151            left: Box::new(TriggerExpr::Lt {
20152                left: TriggerValue::NewColumn(1),
20153                right: TriggerValue::Literal(Value::Int64(0)),
20154            }),
20155            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
20156                TriggerValue::OldColumn(2),
20157            )))),
20158        };
20159        assert!(eval_trigger_expr(
20160            &or_expr,
20161            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
20162        )
20163        .unwrap());
20164        assert!(!eval_trigger_expr(
20165            &or_expr,
20166            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
20167        )
20168        .unwrap());
20169
20170        assert!(eval_trigger_expr(
20171            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
20172            &event_insert(&[])
20173        )
20174        .unwrap());
20175        assert!(!eval_trigger_expr(
20176            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
20177            &event_insert(&[])
20178        )
20179        .unwrap());
20180        assert!(!eval_trigger_expr(
20181            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
20182            &event_insert(&[])
20183        )
20184        .unwrap());
20185    }
20186}
20187
20188#[cfg(test)]
20189mod core_resource_tests {
20190    use super::*;
20191
20192    fn int_pk_schema() -> Schema {
20193        Schema {
20194            columns: vec![ColumnDef {
20195                id: 1,
20196                name: "id".into(),
20197                ty: TypeId::Int64,
20198                flags: crate::schema::ColumnFlags::empty()
20199                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20200                default_value: None,
20201                embedding_source: None,
20202            }],
20203            ..Schema::default()
20204        }
20205    }
20206
20207    #[test]
20208    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
20209        let dir = tempfile::tempdir().unwrap();
20210        let db = Database::create(dir.path()).unwrap();
20211        assert_eq!(
20212            db.memory_governor().max_bytes(),
20213            DEFAULT_MEMORY_BUDGET_BYTES
20214        );
20215        assert_eq!(
20216            db.spill_manager().config().global_bytes,
20217            DEFAULT_TEMP_DISK_BUDGET_BYTES
20218        );
20219        assert!(db.job_registry().list().is_empty());
20220        // S1E-002: class defaults seeded at open; no external embedding vendor.
20221        assert_eq!(
20222            db.resource_groups().len(),
20223            crate::resource::WorkloadClass::ALL.len()
20224        );
20225        assert!(db.resource_groups().get("control").is_some());
20226        assert!(db.embedding_providers().list_ids().is_empty());
20227        // Application-supplied path refuses generation (no silent hashed vectors).
20228        let err = db
20229            .embedding_providers()
20230            .embed(
20231                &crate::embedding::EmbeddingSource::SuppliedByApplication,
20232                &["text"],
20233                4,
20234            )
20235            .unwrap_err();
20236        assert!(matches!(
20237            err,
20238            crate::embedding::EmbeddingError::SuppliedByApplication
20239        ));
20240    }
20241
20242    #[test]
20243    fn lock_rows_for_update_acquires_exclusive_row_locks() {
20244        use crate::locks::LockKey;
20245        use crate::rowid::RowId;
20246
20247        let dir = tempfile::tempdir().unwrap();
20248        let db = Database::create(dir.path()).unwrap();
20249        let txn_id = db.allocate_lock_txn_id().unwrap();
20250        let rid = RowId(42);
20251        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
20252        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20253        db.release_txn_locks(txn_id);
20254        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20255    }
20256
20257    #[test]
20258    fn open_with_options_sizes_the_core_budgets() {
20259        let dir = tempfile::tempdir().unwrap();
20260        let db = Database::create(dir.path()).unwrap();
20261        drop(db);
20262        let db = Database::open_with_options(
20263            dir.path(),
20264            OpenOptions::default()
20265                .with_memory_budget_bytes(256 * 1024 * 1024)
20266                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
20267        )
20268        .unwrap();
20269        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
20270        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
20271    }
20272
20273    #[test]
20274    fn zero_budgets_are_rejected() {
20275        let dir = tempfile::tempdir().unwrap();
20276        let db = Database::create(dir.path()).unwrap();
20277        drop(db);
20278        let result = Database::open_with_options(
20279            dir.path(),
20280            OpenOptions::default().with_memory_budget_bytes(0),
20281        );
20282        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20283        let result = Database::open_with_options(
20284            dir.path(),
20285            OpenOptions::default().with_temp_disk_budget_bytes(0),
20286        );
20287        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20288    }
20289
20290    #[test]
20291    fn page_caches_reserve_under_the_governor() {
20292        let dir = tempfile::tempdir().unwrap();
20293        let db = Database::create(dir.path()).unwrap();
20294        db.create_table("t", int_pk_schema()).unwrap();
20295        let mut txn = db.begin();
20296        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20297        txn.commit().unwrap();
20298        // S1E-003: both caches hold reservations under the core's governor, so
20299        // the governor's per-class accounting mirrors their live bytes, and
20300        // both are registered as reclaimable subsystems it can evict.
20301        let stats = db.memory_governor().stats();
20302        assert_eq!(
20303            stats.usage_for(crate::memory::MemoryClass::PageCache),
20304            db.page_cache.used_bytes()
20305        );
20306        assert_eq!(
20307            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
20308            db.decoded_cache.used_bytes()
20309        );
20310        assert_eq!(
20311            db.memory_governor().reclaimable_bytes(),
20312            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
20313        );
20314        // Driving an eviction through the governor is safe at any level.
20315        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
20316    }
20317
20318    #[test]
20319    fn job_registry_persists_across_reopen() {
20320        let dir = tempfile::tempdir().unwrap();
20321        let db = Database::create(dir.path()).unwrap();
20322        db.create_table("t", int_pk_schema()).unwrap();
20323        let job_id = db
20324            .job_registry()
20325            .submit(
20326                crate::jobs::JobKind::IndexBuild,
20327                crate::jobs::JobTarget {
20328                    table: "t".to_string(),
20329                    index: Some("idx".to_string()),
20330                },
20331            )
20332            .unwrap();
20333        drop(db);
20334        let db = Database::open(dir.path()).unwrap();
20335        let record = db.job_registry().get(job_id).expect("job survives reopen");
20336        assert_eq!(record.state, crate::jobs::JobState::Pending);
20337    }
20338
20339    #[test]
20340    fn spill_manager_open_sweeps_stale_temp_tree() {
20341        let dir = tempfile::tempdir().unwrap();
20342        let db = Database::create(dir.path()).unwrap();
20343        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
20344        std::fs::create_dir_all(&stale).unwrap();
20345        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
20346        drop(db);
20347        let db = Database::open(dir.path()).unwrap();
20348        assert!(
20349            !stale.exists(),
20350            "the startup sweep removes stale spill files (S1E-004)"
20351        );
20352        // A fresh session can start against the swept manager.
20353        let session = db
20354            .spill_manager()
20355            .begin_query(
20356                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
20357                1024 * 1024,
20358            )
20359            .unwrap();
20360        assert_eq!(session.used(), 0);
20361    }
20362}
20363
20364#[cfg(test)]
20365mod version_pin_tests {
20366    use super::*;
20367
20368    fn int_pk_schema() -> Schema {
20369        Schema {
20370            columns: vec![ColumnDef {
20371                id: 1,
20372                name: "id".into(),
20373                ty: TypeId::Int64,
20374                flags: crate::schema::ColumnFlags::empty()
20375                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20376                default_value: None,
20377                embedding_source: None,
20378            }],
20379            ..Schema::default()
20380        }
20381    }
20382
20383    fn pins_for(
20384        report: &[TablePinsReport],
20385        table: &str,
20386        source: crate::retention::PinSource,
20387    ) -> Option<crate::retention::PinInfo> {
20388        report
20389            .iter()
20390            .find(|entry| entry.table == table)
20391            .and_then(|entry| entry.pins.get(source).cloned())
20392    }
20393
20394    #[test]
20395    fn backup_boundary_registers_backup_pitr_pin() {
20396        let source = tempfile::tempdir().unwrap();
20397        let destination_parent = tempfile::tempdir().unwrap();
20398        let destination = destination_parent.path().join("backup");
20399        let db = Arc::new(Database::create(source.path()).unwrap());
20400        db.create_table("t", int_pk_schema()).unwrap();
20401        let mut txn = db.begin();
20402        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
20403        let boundary_epoch = txn.commit().unwrap();
20404
20405        let hold = Arc::new(std::sync::Barrier::new(2));
20406        let resume = Arc::new(std::sync::Barrier::new(2));
20407        db.__set_backup_hook({
20408            let hold = Arc::clone(&hold);
20409            let resume = Arc::clone(&resume);
20410            move || {
20411                hold.wait();
20412                resume.wait();
20413            }
20414        });
20415
20416        let backup = {
20417            let db = Arc::clone(&db);
20418            let destination = destination.clone();
20419            std::thread::spawn(move || db.hot_backup(destination))
20420        };
20421        hold.wait();
20422        // The hook fires while the backup's pins are held: the boundary must
20423        // show up as a BackupPitr pin on the table's unified registry.
20424        let report = db.version_pins_report();
20425        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
20426            .expect("backup boundary must register a BackupPitr pin");
20427        assert_eq!(pin.oldest_epoch, boundary_epoch);
20428        assert!(pin.pin_count >= 1);
20429        resume.wait();
20430        backup.join().unwrap().unwrap();
20431
20432        let report = db.version_pins_report();
20433        assert!(
20434            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
20435            "the BackupPitr pin releases when the backup finishes"
20436        );
20437    }
20438
20439    #[test]
20440    fn snapshot_and_read_generation_pins_surface_in_report() {
20441        let dir = tempfile::tempdir().unwrap();
20442        let db = Database::create(dir.path()).unwrap();
20443        db.create_table("t", int_pk_schema()).unwrap();
20444        let mut txn = db.begin();
20445        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20446        txn.commit().unwrap();
20447
20448        let (_snapshot, guard) = db.snapshot();
20449        let report = db.version_pins_report();
20450        assert!(
20451            pins_for(
20452                &report,
20453                "t",
20454                crate::retention::PinSource::TransactionSnapshot
20455            )
20456            .is_some(),
20457            "a database snapshot projects the TransactionSnapshot source"
20458        );
20459        drop(guard);
20460
20461        let handle = db.table("t").unwrap();
20462        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
20463        let report = db.version_pins_report();
20464        assert!(
20465            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
20466            "a cloned read generation registers a ReadGeneration pin"
20467        );
20468        drop(generation);
20469
20470        let report = db.version_pins_report();
20471        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
20472        assert!(
20473            entry
20474                .pins
20475                .get(crate::retention::PinSource::BackupPitr)
20476                .is_none()
20477                && entry
20478                    .pins
20479                    .get(crate::retention::PinSource::Replication)
20480                    .is_none()
20481                && entry
20482                    .pins
20483                    .get(crate::retention::PinSource::OnlineIndexBuild)
20484                    .is_none(),
20485            "untaken sources stay absent from the report"
20486        );
20487    }
20488}
20489
20490#[cfg(test)]
20491mod lock_manager_tests {
20492    use super::*;
20493    use crate::locks::LockKey;
20494
20495    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
20496        ColumnDef {
20497            id,
20498            name: name.into(),
20499            ty,
20500            flags,
20501            default_value: None,
20502            embedding_source: None,
20503        }
20504    }
20505
20506    fn unique_schema() -> Schema {
20507        let mut constraints = crate::constraint::TableConstraints::default();
20508        constraints
20509            .uniques
20510            .push(crate::constraint::UniqueConstraint {
20511                id: 1,
20512                name: "users_email_unique".into(),
20513                columns: vec![1],
20514            });
20515        Schema {
20516            columns: vec![
20517                col(
20518                    0,
20519                    "id",
20520                    TypeId::Int64,
20521                    crate::schema::ColumnFlags::empty()
20522                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20523                ),
20524                col(
20525                    1,
20526                    "email",
20527                    TypeId::Bytes,
20528                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
20529                ),
20530            ],
20531            constraints,
20532            ..Schema::default()
20533        }
20534    }
20535
20536    fn parent_schema() -> Schema {
20537        Schema {
20538            columns: vec![col(
20539                0,
20540                "id",
20541                TypeId::Int64,
20542                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
20543            )],
20544            ..Schema::default()
20545        }
20546    }
20547
20548    fn child_schema() -> Schema {
20549        let mut constraints = crate::constraint::TableConstraints::default();
20550        constraints
20551            .foreign_keys
20552            .push(crate::constraint::ForeignKey {
20553                id: 1,
20554                name: "child_parent_fk".into(),
20555                columns: vec![1],
20556                ref_table: "parent".into(),
20557                ref_columns: vec![0],
20558                on_delete: crate::constraint::FkAction::Restrict,
20559                on_update: crate::constraint::FkAction::Restrict,
20560            });
20561        Schema {
20562            columns: vec![
20563                col(
20564                    0,
20565                    "id",
20566                    TypeId::Int64,
20567                    crate::schema::ColumnFlags::empty()
20568                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20569                ),
20570                col(
20571                    1,
20572                    "pid",
20573                    TypeId::Int64,
20574                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
20575                ),
20576            ],
20577            constraints,
20578            ..Schema::default()
20579        }
20580    }
20581
20582    fn auto_inc_schema() -> Schema {
20583        Schema {
20584            columns: vec![col(
20585                0,
20586                "id",
20587                TypeId::Int64,
20588                crate::schema::ColumnFlags::empty()
20589                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
20590                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
20591            )],
20592            ..Schema::default()
20593        }
20594    }
20595
20596    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
20597        let mut key = b"pk:".to_vec();
20598        key.extend_from_slice(&Value::Int64(value).encode_key());
20599        LockKey::key(table_id, key)
20600    }
20601
20602    #[test]
20603    fn unique_claims_serialize_concurrent_commits() {
20604        let dir = tempfile::tempdir().unwrap();
20605        let db = Arc::new(Database::create(dir.path()).unwrap());
20606        let table_id = db.create_table("users", unique_schema()).unwrap();
20607        let pk_key = pk_lock_key(table_id, 1);
20608        let entered = Arc::new(std::sync::Barrier::new(2));
20609        let resume = Arc::new(std::sync::Barrier::new(2));
20610        let parked = Arc::new(AtomicBool::new(false));
20611        db.__set_catalog_commit_hook({
20612            let entered = Arc::clone(&entered);
20613            let resume = Arc::clone(&resume);
20614            let parked = Arc::clone(&parked);
20615            move || {
20616                // Park only the first commit to reach the sequencer; later
20617                // commits pass straight through.
20618                if !parked.swap(true, Ordering::SeqCst) {
20619                    entered.wait();
20620                    resume.wait();
20621                }
20622            }
20623        });
20624
20625        let mut txn_a = db.begin();
20626        txn_a
20627            .put(
20628                "users",
20629                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
20630            )
20631            .unwrap();
20632        let a_id = txn_a.txn_id();
20633        let (a_tx, a_rx) = std::sync::mpsc::channel();
20634        let (b_tx, b_rx) = std::sync::mpsc::channel();
20635        std::thread::scope(|scope| {
20636            scope.spawn(|| {
20637                a_tx.send(txn_a.commit()).unwrap();
20638            });
20639            entered.wait();
20640            // A is parked in the sequencer holding its unique claims.
20641            assert!(
20642                db.lock_manager().holds(a_id, &pk_key),
20643                "primary-key claim must be held until the commit ends"
20644            );
20645            let mut uq_key = format!("uq{}:", 1).into_bytes();
20646            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
20647                .into_iter()
20648                .collect();
20649            uq_key.extend_from_slice(
20650                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
20651            );
20652            assert!(
20653                db.lock_manager()
20654                    .holds(a_id, &LockKey::key(table_id, uq_key)),
20655                "declared-unique claim must be held until the commit ends"
20656            );
20657
20658            let mut txn_b = db.begin();
20659            txn_b
20660                .put(
20661                    "users",
20662                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
20663                )
20664                .unwrap();
20665            scope.spawn(|| {
20666                b_tx.send(txn_b.commit()).unwrap();
20667            });
20668            std::thread::sleep(std::time::Duration::from_millis(100));
20669            assert!(
20670                b_rx.try_recv().is_err(),
20671                "the concurrent claim must block until A ends its transaction"
20672            );
20673            resume.wait();
20674            assert!(a_rx.recv().unwrap().is_ok());
20675            let b_result = b_rx.recv().unwrap();
20676            assert!(
20677                matches!(b_result, Err(MongrelError::Conflict(_))),
20678                "the loser surfaces a conflict after serializing: {b_result:?}"
20679            );
20680        });
20681        assert!(
20682            !db.lock_manager().holds(a_id, &pk_key),
20683            "no phantom holds remain after the commit"
20684        );
20685    }
20686
20687    #[test]
20688    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
20689        let dir = tempfile::tempdir().unwrap();
20690        let db = Arc::new(Database::create(dir.path()).unwrap());
20691        db.create_table("parent", parent_schema()).unwrap();
20692        db.create_table("child", child_schema()).unwrap();
20693        let mut seed = db.begin();
20694        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
20695        seed.commit().unwrap();
20696
20697        let entered = Arc::new(std::sync::Barrier::new(2));
20698        let resume = Arc::new(std::sync::Barrier::new(2));
20699        db.__set_fk_lock_hook({
20700            let entered = Arc::clone(&entered);
20701            let resume = Arc::clone(&resume);
20702            move || {
20703                entered.wait();
20704                resume.wait();
20705            }
20706        });
20707
20708        let mut txn_a = db.begin();
20709        txn_a
20710            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
20711            .unwrap();
20712        let a_id = txn_a.txn_id();
20713        let (a_tx, a_rx) = std::sync::mpsc::channel();
20714        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
20715        std::thread::scope(|scope| {
20716            scope.spawn(|| {
20717                a_tx.send(txn_a.commit()).unwrap();
20718            });
20719            entered.wait();
20720            // A is parked mid-validation: schema barrier held Shared.
20721            assert!(
20722                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
20723                "DML holds the schema barrier Shared for its commit"
20724            );
20725            let db = Arc::clone(&db);
20726            scope.spawn(move || {
20727                ddl_tx.send(db.drop_table("parent")).unwrap();
20728            });
20729            std::thread::sleep(std::time::Duration::from_millis(100));
20730            assert!(
20731                ddl_rx.try_recv().is_err(),
20732                "DDL must wait on the Exclusive schema barrier while DML is in flight"
20733            );
20734            resume.wait();
20735            // A now finishes and the waiting DDL proceeds. A's commit may
20736            // legitimately lose the publish race against the DDL's security
20737            // version advance — the designed "security policy changed during
20738            // write" outcome — or win it; the barrier guarantees only that the
20739            // DDL could not proceed before A released it.
20740            let a_result = a_rx.recv().unwrap();
20741            match &a_result {
20742                Ok(_) => {}
20743                Err(MongrelError::Conflict(message)) => {
20744                    assert!(
20745                        message.contains("security policy changed during write"),
20746                        "unexpected commit conflict: {message}"
20747                    );
20748                }
20749                other => panic!("unexpected commit outcome: {other:?}"),
20750            }
20751            assert!(ddl_rx.recv().unwrap().is_ok());
20752        });
20753        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
20754    }
20755
20756    #[test]
20757    fn auto_increment_sequence_barrier_held_until_commit() {
20758        let dir = tempfile::tempdir().unwrap();
20759        let db = Database::create(dir.path()).unwrap();
20760        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
20761        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
20762        let entered = Arc::new(std::sync::Barrier::new(2));
20763        let resume = Arc::new(std::sync::Barrier::new(2));
20764        let parked = Arc::new(AtomicBool::new(false));
20765        db.__set_catalog_commit_hook({
20766            let entered = Arc::clone(&entered);
20767            let resume = Arc::clone(&resume);
20768            let parked = Arc::clone(&parked);
20769            move || {
20770                if !parked.swap(true, Ordering::SeqCst) {
20771                    entered.wait();
20772                    resume.wait();
20773                }
20774            }
20775        });
20776
20777        let mut txn_a = db.begin();
20778        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
20779        let a_id = txn_a.txn_id();
20780        // The stage-time allocation already holds the barrier.
20781        assert!(
20782            db.lock_manager().holds(a_id, &barrier_key),
20783            "sequence allocation takes the barrier at stage time"
20784        );
20785        let (a_tx, a_rx) = std::sync::mpsc::channel();
20786        std::thread::scope(|scope| {
20787            scope.spawn(|| {
20788                a_tx.send(txn_a.commit()).unwrap();
20789            });
20790            entered.wait();
20791            assert!(
20792                db.lock_manager().holds(a_id, &barrier_key),
20793                "the barrier is held through the commit"
20794            );
20795            resume.wait();
20796            assert!(a_rx.recv().unwrap().is_ok());
20797        });
20798        assert!(
20799            !db.lock_manager().holds(a_id, &barrier_key),
20800            "the barrier releases when the commit ends"
20801        );
20802    }
20803
20804    #[test]
20805    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
20806        let dir = tempfile::tempdir().unwrap();
20807        let db = Database::create(dir.path()).unwrap();
20808        db.create_table("parent", parent_schema()).unwrap();
20809        db.create_table("child", child_schema()).unwrap();
20810        let mut seed = db.begin();
20811        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
20812        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
20813        seed.commit().unwrap();
20814        let (rid1, rid2) = {
20815            let handle = db.table("parent").unwrap();
20816            let table = handle.lock();
20817            let rid = |pk: i64| {
20818                table
20819                    .lookup_pk(&Value::Int64(pk).encode_key())
20820                    .expect("seeded parent row")
20821            };
20822            (rid(1), rid(2))
20823        };
20824
20825        // The hook fires after every successful FK lock acquisition; park the
20826        // first two (A's and B's Exclusive delete-side claims) so both are
20827        // held before either transaction attempts its Shared insert-side
20828        // claim — a deterministic wait-for cycle.
20829        let rendezvous = Arc::new(std::sync::Barrier::new(2));
20830        let calls = Arc::new(AtomicUsize::new(0));
20831        db.__set_fk_lock_hook({
20832            let rendezvous = Arc::clone(&rendezvous);
20833            let calls = Arc::clone(&calls);
20834            move || {
20835                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
20836                    rendezvous.wait();
20837                }
20838            }
20839        });
20840
20841        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
20842        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
20843        let mut txn_a = db.begin();
20844        txn_a.delete("parent", rid1).unwrap();
20845        txn_a
20846            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
20847            .unwrap();
20848        let mut txn_b = db.begin();
20849        txn_b.delete("parent", rid2).unwrap();
20850        txn_b
20851            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
20852            .unwrap();
20853        let b_id = txn_b.txn_id();
20854
20855        let (a_tx, a_rx) = std::sync::mpsc::channel();
20856        let (b_tx, b_rx) = std::sync::mpsc::channel();
20857        std::thread::scope(|scope| {
20858            scope.spawn(|| {
20859                a_tx.send(txn_a.commit()).unwrap();
20860            });
20861            scope.spawn(|| {
20862                b_tx.send(txn_b.commit()).unwrap();
20863            });
20864            let a_result = a_rx.recv().unwrap();
20865            let b_result = b_rx.recv().unwrap();
20866            assert!(
20867                a_result.is_ok(),
20868                "the survivor commits once the victim releases: {a_result:?}"
20869            );
20870            match b_result {
20871                Err(MongrelError::Deadlock { victim, .. }) => {
20872                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
20873                }
20874                other => panic!("the victim must surface a deadlock, got {other:?}"),
20875            }
20876        });
20877        // No phantom holds survive the victim's release.
20878        let fk_key = |table: &str, pk: i64| {
20879            let table_id = db.table_id(table).unwrap();
20880            let mut key = b"fk:".to_vec();
20881            key.extend_from_slice(&Value::Int64(pk).encode_key());
20882            LockKey::key(table_id, key)
20883        };
20884        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
20885        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
20886    }
20887
20888    #[test]
20889    fn locks_release_after_commit_rollback_and_failed_commit() {
20890        let dir = tempfile::tempdir().unwrap();
20891        let db = Database::create(dir.path()).unwrap();
20892        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
20893        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
20894
20895        // Successful commit: the stage-time barrier releases with the commit.
20896        let mut txn = db.begin();
20897        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
20898        let committed_id = txn.txn_id();
20899        txn.commit().unwrap();
20900        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
20901
20902        // Rollback: the stage-time barrier releases with the drop.
20903        let mut txn = db.begin();
20904        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
20905        let rolled_back_id = txn.txn_id();
20906        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
20907        txn.rollback();
20908        assert!(
20909            !db.lock_manager().holds(rolled_back_id, &barrier_key),
20910            "rollback must not leave phantom holds"
20911        );
20912
20913        // Failed commit (declared-unique violation): the claims release with
20914        // the error.
20915        db.create_table("users", unique_schema()).unwrap();
20916        let users_id = db.table_id("users").unwrap();
20917        let mut seed = db.begin();
20918        seed.put(
20919            "users",
20920            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
20921        )
20922        .unwrap();
20923        seed.commit().unwrap();
20924        let mut txn = db.begin();
20925        // A different primary key but the same declared-unique email: the
20926        // Phase B unique check rejects this commit.
20927        txn.put(
20928            "users",
20929            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
20930        )
20931        .unwrap();
20932        let failed_id = txn.txn_id();
20933        let result = txn.commit();
20934        assert!(matches!(result, Err(MongrelError::Conflict(_))));
20935        assert!(
20936            !db.lock_manager()
20937                .holds(failed_id, &pk_lock_key(users_id, 2)),
20938            "a failed commit must not leave phantom holds"
20939        );
20940    }
20941}
20942
20943#[cfg(test)]
20944mod lifecycle_tests {
20945    use super::*;
20946
20947    fn int_pk_schema() -> Schema {
20948        Schema {
20949            columns: vec![ColumnDef {
20950                id: 1,
20951                name: "id".into(),
20952                ty: TypeId::Int64,
20953                flags: crate::schema::ColumnFlags::empty()
20954                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20955                default_value: None,
20956                embedding_source: None,
20957            }],
20958            ..Schema::default()
20959        }
20960    }
20961
20962    #[test]
20963    fn poisoned_core_rejects_operations_with_typed_errors() {
20964        let dir = tempfile::tempdir().unwrap();
20965        let db = Database::create(dir.path()).unwrap();
20966        db.create_table("t", int_pk_schema()).unwrap();
20967        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
20968
20969        // Drive the exact two-state poison the fsync-error sites set
20970        // (write-path flag + lifecycle transition), without process-global
20971        // fault injection, which would leak into parallel tests. The fsync
20972        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
20973        db.poisoned.store(true, Ordering::Relaxed);
20974        db.lifecycle.poison();
20975        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
20976
20977        // Guarded operations without their own write-path poison check reject
20978        // at admission with the lifecycle Conflict...
20979        let error = db.gc().unwrap_err();
20980        assert!(
20981            matches!(error, MongrelError::Conflict(_)),
20982            "gc must reject on a poisoned core: {error:?}"
20983        );
20984        let error = db.compact().unwrap_err();
20985        assert!(
20986            matches!(error, MongrelError::Conflict(_)),
20987            "compact must reject on a poisoned core: {error:?}"
20988        );
20989        assert!(db.operation_guard().is_err());
20990        // ...while paths that already checked the write-path flag keep their
20991        // legacy error.
20992        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
20993        assert!(
20994            error.to_string().contains("database poisoned"),
20995            "the legacy poison error still wins where it existed: {error:?}"
20996        );
20997        let mut txn = db.begin();
20998        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
20999        assert!(txn
21000            .commit()
21001            .unwrap_err()
21002            .to_string()
21003            .contains("database poisoned"));
21004    }
21005
21006    #[test]
21007    fn shutdown_waits_for_operation_guards_to_drain() {
21008        let dir = tempfile::tempdir().unwrap();
21009        let db = Arc::new(Database::create(dir.path()).unwrap());
21010        db.create_table("t", int_pk_schema()).unwrap();
21011        // The guard holds the lifecycle's Arc, not the database's, so the
21012        // exclusive-owner shutdown can proceed to its drain step below.
21013        let guard = db.operation_guard().unwrap();
21014        let (started_tx, started_rx) = std::sync::mpsc::channel();
21015        let (done_tx, done_rx) = std::sync::mpsc::channel();
21016        let shutdown_thread = std::thread::spawn(move || {
21017            started_tx.send(()).unwrap();
21018            let result = db.shutdown();
21019            let _ = done_tx.send(result);
21020        });
21021        started_rx.recv().unwrap();
21022        std::thread::sleep(std::time::Duration::from_millis(100));
21023        assert!(
21024            done_rx.try_recv().is_err(),
21025            "shutdown must wait for the outstanding guard to drain"
21026        );
21027        drop(guard);
21028        shutdown_thread.join().unwrap();
21029        assert!(
21030            done_rx.recv().unwrap().is_ok(),
21031            "shutdown completes once the guard drops"
21032        );
21033    }
21034}
21035
21036#[cfg(test)]
21037mod commit_ts_ledger_tests {
21038    use super::*;
21039
21040    fn int_pk_schema() -> Schema {
21041        Schema {
21042            columns: vec![ColumnDef {
21043                id: 1,
21044                name: "id".into(),
21045                ty: TypeId::Int64,
21046                flags: crate::schema::ColumnFlags::empty()
21047                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21048                default_value: None,
21049                embedding_source: None,
21050            }],
21051            ..Schema::default()
21052        }
21053    }
21054
21055    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
21056        let mut txn = db.begin();
21057        let handle = txn.state_handle();
21058        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
21059        let epoch = txn.commit().unwrap();
21060        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
21061            panic!("expected Committed, got {:?}", handle.state());
21062        };
21063        (epoch, receipt.commit_ts)
21064    }
21065
21066    #[test]
21067    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
21068        let dir = tempfile::tempdir().unwrap();
21069        let db = Database::create(dir.path()).unwrap();
21070        db.create_table("t", int_pk_schema()).unwrap();
21071
21072        let (epoch, commit_ts) = commit_one(&db);
21073        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
21074        // An epoch no commit sealed misses (callers fall back).
21075        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
21076    }
21077
21078    #[test]
21079    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
21080        let dir = tempfile::tempdir().unwrap();
21081        let (epoch, commit_ts) = {
21082            let db = Database::create(dir.path()).unwrap();
21083            db.create_table("t", int_pk_schema()).unwrap();
21084            commit_one(&db)
21085        };
21086
21087        let db = Database::open(dir.path()).unwrap();
21088        let reconstructed = db
21089            .commit_ts_for_epoch(epoch)
21090            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
21091        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
21092        // The ledger byte format stores micros only (spec §8.1): the logical
21093        // counter and tiebreaker reconstruct as 0.
21094        assert_eq!(reconstructed.logical, 0);
21095        assert_eq!(reconstructed.node_tiebreaker, 0);
21096    }
21097
21098    #[test]
21099    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
21100        use crate::wal::Op;
21101        let records = vec![
21102            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
21103            crate::wal::Record::new(
21104                Epoch(2),
21105                7,
21106                Op::TxnCommit {
21107                    epoch: 41,
21108                    added_runs: vec![],
21109                },
21110            ),
21111            // No CommitTimestamp for txn 8: not reconstructible.
21112            crate::wal::Record::new(
21113                Epoch(3),
21114                8,
21115                Op::TxnCommit {
21116                    epoch: 42,
21117                    added_runs: vec![],
21118                },
21119            ),
21120            // Timestamp without a commit marker: aborted, not reconstructible.
21121            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
21122        ];
21123        let ledger = commit_ts_ledger_from_recovery(&records);
21124        assert_eq!(ledger.len(), 1);
21125        assert_eq!(
21126            ledger.get(&41),
21127            Some(&mongreldb_types::hlc::HlcTimestamp {
21128                physical_micros: 1,
21129                logical: 0,
21130                node_tiebreaker: 0,
21131            })
21132        );
21133    }
21134}
21135
21136#[cfg(test)]
21137mod stage2e_storage_mode_tests {
21138    use super::*;
21139    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21140    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
21141    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21142
21143    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
21144        (
21145            ClusterId::from_bytes([seed; 16]),
21146            NodeId::from_bytes([seed + 1; 16]),
21147            DatabaseId::from_bytes([seed + 2; 16]),
21148        )
21149    }
21150
21151    fn marker(root: &Path) -> Option<StorageMode> {
21152        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
21153        crate::storage_mode::read(&durable).unwrap()
21154    }
21155
21156    fn simple_schema() -> Schema {
21157        Schema {
21158            columns: vec![ColumnDef {
21159                id: 1,
21160                name: "id".into(),
21161                ty: TypeId::Int64,
21162                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21163                default_value: None,
21164                embedding_source: None,
21165            }],
21166            ..Schema::default()
21167        }
21168    }
21169
21170    #[test]
21171    fn standalone_create_writes_marker_and_reopens() {
21172        let dir = tempfile::tempdir().unwrap();
21173        let root = dir.path().join("db");
21174        let db = Database::create(&root).unwrap();
21175        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21176        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
21177        drop(db);
21178        let db = Database::open(&root).unwrap();
21179        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21180        drop(db);
21181    }
21182
21183    #[test]
21184    fn legacy_database_without_marker_opens_and_gains_marker() {
21185        let dir = tempfile::tempdir().unwrap();
21186        let root = dir.path().join("db");
21187        let db = Database::create(&root).unwrap();
21188        drop(db);
21189        // Simulate a pre-marker database.
21190        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
21191        assert_eq!(marker(&root), None);
21192        let db = Database::open(&root).unwrap();
21193        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21194        drop(db);
21195    }
21196
21197    #[test]
21198    fn server_owned_standalone_opens_embedded() {
21199        let dir = tempfile::tempdir().unwrap();
21200        let root = dir.path().join("db");
21201        let db = Database::create(&root).unwrap();
21202        drop(db);
21203        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
21204        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
21205        let db = Database::open(&root).unwrap();
21206        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
21207        drop(db);
21208    }
21209
21210    #[test]
21211    fn cluster_replica_is_rejected_by_normal_opens() {
21212        let dir = tempfile::tempdir().unwrap();
21213        let root = dir.path().join("db");
21214        let (cluster_id, node_id, database_id) = identity(10);
21215        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21216        assert_eq!(
21217            marker(&root),
21218            Some(StorageMode::ClusterReplica {
21219                cluster_id,
21220                node_id,
21221                database_id,
21222            })
21223        );
21224        drop(db);
21225
21226        let error = Database::open(&root).unwrap_err();
21227        let message = error.to_string();
21228        assert!(
21229            matches!(error, MongrelError::InvalidArgument(_)),
21230            "unexpected error: {message}"
21231        );
21232        assert!(message.contains("cluster node runtime"), "{message}");
21233        assert!(message.contains(&cluster_id.to_hex()), "{message}");
21234        assert!(message.contains(&node_id.to_hex()), "{message}");
21235        assert!(message.contains(&database_id.to_hex()), "{message}");
21236
21237        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
21238        assert!(error.to_string().contains("cluster node runtime"));
21239        // The rejected opens never disturbed the marker.
21240        assert_eq!(
21241            marker(&root),
21242            Some(StorageMode::ClusterReplica {
21243                cluster_id,
21244                node_id,
21245                database_id,
21246            })
21247        );
21248    }
21249
21250    #[test]
21251    fn offline_validation_opens_cluster_replica_read_only() {
21252        let dir = tempfile::tempdir().unwrap();
21253        let root = dir.path().join("db");
21254        let (cluster_id, node_id, database_id) = identity(20);
21255        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21256        drop(db);
21257
21258        let options = OpenOptions::default().with_offline_validation(true);
21259        let db = Database::open_with_options(&root, options).unwrap();
21260        assert!(db.is_read_only_replica());
21261        let error = db.create_table("t", simple_schema()).unwrap_err();
21262        assert!(matches!(error, MongrelError::ReadOnlyReplica));
21263        drop(db);
21264        // Offline validation leaves the marker exactly as found.
21265        assert_eq!(
21266            marker(&root),
21267            Some(StorageMode::ClusterReplica {
21268                cluster_id,
21269                node_id,
21270                database_id,
21271            })
21272        );
21273    }
21274
21275    #[test]
21276    fn cluster_runtime_open_requires_exact_identity() {
21277        let dir = tempfile::tempdir().unwrap();
21278        let root = dir.path().join("db");
21279        let (cluster_id, node_id, database_id) = identity(30);
21280        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21281        drop(db);
21282
21283        // A non-ClusterReplica expectation is a caller error.
21284        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
21285        assert!(matches!(error, MongrelError::InvalidArgument(_)));
21286        // Wrong database identity fails closed.
21287        let wrong = StorageMode::ClusterReplica {
21288            cluster_id,
21289            node_id,
21290            database_id: DatabaseId::from_bytes([99; 16]),
21291        };
21292        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
21293        assert!(error.to_string().contains("identity mismatch"), "{error}");
21294        // A legacy database without a marker is not a cluster replica.
21295        let legacy = dir.path().join("legacy");
21296        let legacy_db = Database::create(&legacy).unwrap();
21297        drop(legacy_db);
21298        let expected = StorageMode::ClusterReplica {
21299            cluster_id,
21300            node_id,
21301            database_id,
21302        };
21303        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
21304        assert!(error.to_string().contains("identity mismatch"), "{error}");
21305
21306        // The matching identity opens; user writes are rejected (writes
21307        // arrive through the replicated apply path only).
21308        let db = Database::open_cluster_replica(&root, &expected).unwrap();
21309        assert!(db.is_read_only_replica());
21310        let error = db.create_table("t", simple_schema()).unwrap_err();
21311        assert!(matches!(error, MongrelError::ReadOnlyReplica));
21312        drop(db);
21313    }
21314}
21315
21316#[cfg(test)]
21317mod stage2e_replicated_apply_tests {
21318    use super::*;
21319    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
21320    use crate::memtable::{Row, Value};
21321    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21322    use crate::wal::{Op, Record};
21323    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21324    use std::sync::Arc;
21325
21326    fn ids() -> (ClusterId, NodeId, DatabaseId) {
21327        (
21328            ClusterId::from_bytes([1; 16]),
21329            NodeId::from_bytes([2; 16]),
21330            DatabaseId::from_bytes([3; 16]),
21331        )
21332    }
21333
21334    fn expected_mode() -> crate::storage_mode::StorageMode {
21335        let (cluster_id, node_id, database_id) = ids();
21336        crate::storage_mode::StorageMode::ClusterReplica {
21337            cluster_id,
21338            node_id,
21339            database_id,
21340        }
21341    }
21342
21343    fn simple_schema() -> Schema {
21344        Schema {
21345            columns: vec![ColumnDef {
21346                id: 1,
21347                name: "id".into(),
21348                ty: TypeId::Int64,
21349                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21350                default_value: None,
21351                embedding_source: None,
21352            }],
21353            ..Schema::default()
21354        }
21355    }
21356
21357    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
21358        CatalogCommandRecord {
21359            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21360            catalog_version,
21361            command: CatalogCommand::CreateTable {
21362                name: name.to_string(),
21363                schema: simple_schema(),
21364                created_epoch: 1,
21365            },
21366        }
21367    }
21368
21369    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
21370        let rows: Vec<Row> = values
21371            .iter()
21372            .map(|value| {
21373                // Distinct row ids per value so batches never overwrite each
21374                // other's MVCC versions.
21375                Row::new(crate::RowId(*value as u64), Epoch(epoch))
21376                    .with_column(1, Value::Int64(*value))
21377            })
21378            .collect();
21379        vec![
21380            Record::new(
21381                Epoch(0),
21382                txn_id,
21383                Op::Put {
21384                    table_id,
21385                    rows: bincode::serialize(&rows).unwrap(),
21386                },
21387            ),
21388            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
21389            Record::new(
21390                Epoch(0),
21391                txn_id,
21392                Op::TxnCommit {
21393                    epoch,
21394                    added_runs: Vec::new(),
21395                },
21396            ),
21397        ]
21398    }
21399
21400    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
21401        let handle = db.table(table).unwrap();
21402        let rows = handle
21403            .lock()
21404            .visible_rows(crate::epoch::Snapshot::at(Epoch(u64::MAX)))
21405            .unwrap();
21406        let mut values: Vec<i64> = rows
21407            .iter()
21408            .map(|row| match row.columns.get(&1) {
21409                Some(Value::Int64(value)) => *value,
21410                other => panic!("unexpected column: {other:?}"),
21411            })
21412            .collect();
21413        values.sort_unstable();
21414        values
21415    }
21416
21417    #[test]
21418    fn catalog_command_mounts_table_and_replays_as_noop() {
21419        let dir = tempfile::tempdir().unwrap();
21420        let (cluster_id, node_id, database_id) = ids();
21421        let db =
21422            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
21423
21424        let record = create_table_record("items", 1);
21425        let delta = db.apply_replicated_catalog_command(&record).unwrap();
21426        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
21427        assert_eq!(db.table_names(), vec!["items".to_string()]);
21428        assert_eq!(db.catalog_version(), 1);
21429
21430        // Idempotent replay of the same record.
21431        let delta = db.apply_replicated_catalog_command(&record).unwrap();
21432        assert!(matches!(delta, CatalogDelta::NoOp));
21433        assert_eq!(db.table_names().len(), 1);
21434        drop(db);
21435
21436        // The command was checkpointed: the table survives reopen.
21437        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
21438        assert_eq!(db.table_names(), vec!["items".to_string()]);
21439        assert_eq!(db.catalog_version(), 1);
21440    }
21441
21442    #[test]
21443    fn records_apply_rows_and_skip_replays_across_restart() {
21444        let dir = tempfile::tempdir().unwrap();
21445        let (cluster_id, node_id, database_id) = ids();
21446        let db =
21447            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
21448        db.apply_replicated_catalog_command(&create_table_record("items", 1))
21449            .unwrap();
21450
21451        let records = put_records(1, 0, 2, &[10, 20, 30]);
21452        assert!(db.apply_replicated_records(&records).unwrap());
21453        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
21454        assert_eq!(db.visible_epoch(), Epoch(2));
21455
21456        // Crash-window redelivery of the same committed transaction is a
21457        // side-effect-free replay.
21458        assert!(!db.apply_replicated_records(&records).unwrap());
21459        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
21460
21461        // A later transaction at a higher epoch still applies.
21462        let later = put_records(2, 0, 3, &[40]);
21463        assert!(db.apply_replicated_records(&later).unwrap());
21464        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
21465        let db = Arc::new(db);
21466        db.shutdown().unwrap();
21467
21468        // Restart: the local WAL replays the applied rows, and the state
21469        // machine's redelivery is recognized as a replay — no double-apply.
21470        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
21471        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
21472        assert!(!db.apply_replicated_records(&later).unwrap());
21473        assert!(!db.apply_replicated_records(&records).unwrap());
21474        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
21475    }
21476
21477    #[test]
21478    fn spilled_run_commits_fail_closed_this_wave() {
21479        let dir = tempfile::tempdir().unwrap();
21480        let (cluster_id, node_id, database_id) = ids();
21481        let db =
21482            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
21483        db.apply_replicated_catalog_command(&create_table_record("items", 1))
21484            .unwrap();
21485        let mut records = put_records(1, 0, 2, &[10]);
21486        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
21487            panic!("put_records ends in TxnCommit");
21488        };
21489        added_runs.push(crate::wal::AddedRun {
21490            table_id: 0,
21491            run_id: 7,
21492            row_count: 1,
21493            level: 0,
21494            min_row_id: 1,
21495            max_row_id: 1,
21496            content_hash: [0; 32],
21497        });
21498        let error = db.apply_replicated_records(&records).unwrap_err();
21499        assert!(
21500            error.to_string().contains("spilled-run"),
21501            "unexpected error: {error}"
21502        );
21503        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
21504    }
21505
21506    #[test]
21507    fn records_without_commit_marker_fail_closed() {
21508        let dir = tempfile::tempdir().unwrap();
21509        let (cluster_id, node_id, database_id) = ids();
21510        let db =
21511            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
21512        db.apply_replicated_catalog_command(&create_table_record("items", 1))
21513            .unwrap();
21514        let mut records = put_records(1, 0, 2, &[10]);
21515        records.pop(); // strip the TxnCommit
21516        let error = db.apply_replicated_records(&records).unwrap_err();
21517        assert!(matches!(error, MongrelError::InvalidArgument(_)));
21518        assert!(db.apply_replicated_records(&[]).is_err());
21519        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
21520    }
21521}
21522
21523#[cfg(test)]
21524mod stage2c_spill_translation_tests {
21525    use super::*;
21526    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
21527    use crate::memtable::{Row, Value};
21528    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21529    use crate::wal::{Op, Record};
21530    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21531
21532    fn simple_schema() -> Schema {
21533        Schema {
21534            columns: vec![ColumnDef {
21535                id: 1,
21536                name: "id".into(),
21537                ty: TypeId::Int64,
21538                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21539                default_value: None,
21540                embedding_source: None,
21541            }],
21542            ..Schema::default()
21543        }
21544    }
21545
21546    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
21547        CatalogCommandRecord {
21548            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21549            catalog_version,
21550            command: CatalogCommand::CreateTable {
21551                name: name.to_string(),
21552                schema: simple_schema(),
21553                created_epoch: 1,
21554            },
21555        }
21556    }
21557
21558    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
21559        let handle = db.table(table).unwrap();
21560        let rows = handle
21561            .lock()
21562            .visible_rows(crate::epoch::Snapshot::at(Epoch(u64::MAX)))
21563            .unwrap();
21564        let mut values: Vec<i64> = rows
21565            .iter()
21566            .map(|row| match row.columns.get(&1) {
21567                Some(Value::Int64(value)) => *value,
21568                other => panic!("unexpected column: {other:?}"),
21569            })
21570            .collect();
21571        values.sort_unstable();
21572        values
21573    }
21574
21575    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
21576        let rows: Vec<Row> = values
21577            .iter()
21578            .map(|value| {
21579                Row::new(crate::RowId(*value as u64), Epoch(epoch))
21580                    .with_column(1, Value::Int64(*value))
21581            })
21582            .collect();
21583        vec![
21584            Record::new(
21585                Epoch(0),
21586                txn_id,
21587                Op::Put {
21588                    table_id,
21589                    rows: bincode::serialize(&rows).unwrap(),
21590                },
21591            ),
21592            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
21593            Record::new(
21594                Epoch(0),
21595                txn_id,
21596                Op::TxnCommit {
21597                    epoch,
21598                    added_runs: Vec::new(),
21599                },
21600            ),
21601        ]
21602    }
21603
21604    fn added_run(
21605        table_id: u64,
21606        row_count: u64,
21607        min_row_id: u64,
21608        max_row_id: u64,
21609    ) -> crate::wal::AddedRun {
21610        crate::wal::AddedRun {
21611            table_id,
21612            run_id: 7,
21613            row_count,
21614            level: 0,
21615            min_row_id,
21616            max_row_id,
21617            content_hash: [0; 32],
21618        }
21619    }
21620
21621    /// Replays the shared WAL of `db` and returns every record of the one
21622    /// transaction whose commit marker links spilled runs.
21623    fn spilled_commit_records(db: &Database) -> Vec<Record> {
21624        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
21625        let txn_id = records
21626            .iter()
21627            .find_map(|record| match &record.op {
21628                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
21629                _ => None,
21630            })
21631            .expect("a spilled commit is present in the WAL");
21632        records
21633            .into_iter()
21634            .filter(|record| record.txn_id == txn_id)
21635            .collect()
21636    }
21637
21638    #[test]
21639    fn non_spilled_records_translate_byte_identical() {
21640        let records = put_records(1, 0, 2, &[10, 20, 30]);
21641        let translated = translate_records_for_replication(&records).unwrap();
21642        assert_eq!(
21643            bincode::serialize(&translated).unwrap(),
21644            bincode::serialize(&records).unwrap(),
21645            "a commit without spill links must pass through byte-identical"
21646        );
21647    }
21648
21649    #[test]
21650    fn translation_rejects_uncovered_or_malformed_spills() {
21651        // added_runs with no logical spill records at all: rejected.
21652        let mut records = put_records(1, 0, 2, &[10]);
21653        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
21654            panic!("put_records ends in TxnCommit");
21655        };
21656        added_runs.push(added_run(0, 1, 10, 10));
21657        let error = translate_records_for_replication(&records).unwrap_err();
21658        assert!(
21659            error.to_string().contains("no logical row records"),
21660            "unexpected error: {error}"
21661        );
21662
21663        // Coverage present but short of the linked row count: rejected.
21664        let mut records = put_records(1, 0, 2, &[10]);
21665        let spilled: Vec<Row> = (0..3_u64)
21666            .map(|value| {
21667                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
21668            })
21669            .collect();
21670        records.insert(
21671            0,
21672            Record::new(
21673                Epoch(0),
21674                1,
21675                Op::SpilledRows {
21676                    table_id: 0,
21677                    rows: bincode::serialize(&spilled).unwrap(),
21678                },
21679            ),
21680        );
21681        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
21682            panic!("put_records ends in TxnCommit");
21683        };
21684        added_runs.push(added_run(0, 4, 0, 3));
21685        let error = translate_records_for_replication(&records).unwrap_err();
21686        assert!(
21687            error.to_string().contains("cover 3 rows"),
21688            "unexpected error: {error}"
21689        );
21690
21691        // An undecodable spill payload: rejected at propose time, never at apply.
21692        let mut records = put_records(1, 0, 2, &[10]);
21693        records.insert(
21694            0,
21695            Record::new(
21696                Epoch(0),
21697                1,
21698                Op::SpilledRows {
21699                    table_id: 0,
21700                    rows: vec![0xFF, 0x01, 0x02],
21701                },
21702            ),
21703        );
21704        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
21705            panic!("put_records ends in TxnCommit");
21706        };
21707        added_runs.push(added_run(0, 1, 0, 0));
21708        assert!(translate_records_for_replication(&records).is_err());
21709
21710        // Structural violations mirror the apply-side contract.
21711        assert!(translate_records_for_replication(&[]).is_err());
21712        let mut mixed = put_records(1, 0, 2, &[10]);
21713        mixed[0].txn_id = 99;
21714        assert!(translate_records_for_replication(&mixed).is_err());
21715        let mut no_commit = put_records(1, 0, 2, &[10]);
21716        no_commit.pop();
21717        assert!(translate_records_for_replication(&no_commit).is_err());
21718    }
21719
21720    #[test]
21721    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
21722        // A real standalone commit that spills (spec section 8.5).
21723        let leader_dir = tempfile::tempdir().unwrap();
21724        let leader = Database::create(leader_dir.path()).unwrap();
21725        leader.create_table("t", simple_schema()).unwrap();
21726        leader.set_spill_threshold(1);
21727        let table_id = leader.table_id("t").unwrap();
21728        let values: Vec<i64> = (0..60).collect();
21729        leader
21730            .transaction(|txn| {
21731                for value in &values {
21732                    txn.put("t", vec![(1, Value::Int64(*value))])?;
21733                }
21734                Ok(())
21735            })
21736            .unwrap();
21737        assert_eq!(visible_ids(&leader, "t"), values);
21738
21739        // The leader's own WAL keeps the spill shape: SpilledRows records
21740        // plus an added_runs commit marker.
21741        let records = spilled_commit_records(&leader);
21742        assert!(records
21743            .iter()
21744            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
21745        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
21746            panic!("a commit sequence ends in TxnCommit");
21747        };
21748        assert!(!added_runs.is_empty());
21749        let commit_epoch = *epoch;
21750
21751        // Translation strips every run reference and keeps the rows as
21752        // logical puts; the input sequence is untouched.
21753        let translated = translate_records_for_replication(&records).unwrap();
21754        assert!(records
21755            .iter()
21756            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
21757        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
21758            panic!("a commit sequence ends in TxnCommit");
21759        };
21760        assert!(!added_runs.is_empty(), "input records must be unchanged");
21761        assert_eq!(translated.len(), records.len());
21762        assert!(translated
21763            .iter()
21764            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
21765        assert!(translated
21766            .iter()
21767            .any(|record| matches!(record.op, Op::Put { .. })));
21768        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
21769            panic!("a commit sequence ends in TxnCommit");
21770        };
21771        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
21772        assert_eq!(*epoch, commit_epoch);
21773
21774        // The translated payload applies on a replica with identical rows.
21775        let replica_dir = tempfile::tempdir().unwrap();
21776        let replica = Database::create_cluster_replica(
21777            replica_dir.path(),
21778            ClusterId::from_bytes([1; 16]),
21779            NodeId::from_bytes([2; 16]),
21780            DatabaseId::from_bytes([3; 16]),
21781        )
21782        .unwrap();
21783        replica
21784            .apply_replicated_catalog_command(&create_table_record("t", 1))
21785            .unwrap();
21786        assert_eq!(replica.table_id("t").unwrap(), table_id);
21787        assert!(replica.apply_replicated_records(&translated).unwrap());
21788        assert_eq!(visible_ids(&replica, "t"), values);
21789        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
21790
21791        // Standalone behavior is unchanged: the leader still recovers its
21792        // spilled commit by linking the run file.
21793        drop(leader);
21794        let leader = Database::open(leader_dir.path()).unwrap();
21795        assert_eq!(visible_ids(&leader, "t"), values);
21796    }
21797
21798    #[test]
21799    fn staged_txn_writes_validate_and_apply() {
21800        let dir = tempfile::tempdir().unwrap();
21801        let db = Database::create_cluster_replica(
21802            dir.path(),
21803            ClusterId::from_bytes([1; 16]),
21804            NodeId::from_bytes([2; 16]),
21805            DatabaseId::from_bytes([3; 16]),
21806        )
21807        .unwrap();
21808        db.apply_replicated_catalog_command(&create_table_record("t", 1))
21809            .unwrap();
21810        let table_id = db.table_id("t").unwrap();
21811
21812        // Malformed payloads and unmounted tables are rejected at prepare.
21813        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
21814        let unknown_table = StagedTxnWrite::Put {
21815            table_id: 99,
21816            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
21817        }
21818        .encode()
21819        .unwrap();
21820        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
21821        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
21822            .iter()
21823            .map(|value| {
21824                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
21825                    .with_column(1, Value::Int64(*value))];
21826                StagedTxnWrite::Put {
21827                    table_id,
21828                    rows: bincode::serialize(&rows).unwrap(),
21829                }
21830                .encode()
21831                .unwrap()
21832            })
21833            .collect();
21834        db.validate_staged_txn_writes(&good).unwrap();
21835
21836        // A committed resolution applies the staged writes; a delete
21837        // resolution removes them; both are replay-safe.
21838        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
21839            physical_micros: 5_000,
21840            logical: 0,
21841            node_tiebreaker: 0,
21842        };
21843        assert!(db
21844            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
21845            .unwrap());
21846        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
21847        let delete = StagedTxnWrite::Delete {
21848            table_id,
21849            row_ids: vec![20],
21850        }
21851        .encode()
21852        .unwrap();
21853        assert!(db
21854            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
21855            .unwrap());
21856        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
21857
21858        // Restart: the synthetic WAL transactions replay through the same
21859        // recovery path; the rows are durable.
21860        let db = Arc::new(db);
21861        db.shutdown().unwrap();
21862        let expected = crate::storage_mode::StorageMode::ClusterReplica {
21863            cluster_id: ClusterId::from_bytes([1; 16]),
21864            node_id: NodeId::from_bytes([2; 16]),
21865            database_id: DatabaseId::from_bytes([3; 16]),
21866        };
21867        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
21868        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
21869    }
21870}