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                commit_ts: None,
974            })
975            .collect::<Vec<_>>();
976        let mut total = 0;
977        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
978        assert!(matches!(
979            error,
980            MongrelError::ResourceLimitExceeded {
981                resource: "spilled WAL transaction bytes",
982                ..
983            }
984        ));
985    }
986}
987
988/// Move spill files to their final names before the WAL commit. Dropping this
989/// guard restores pending names while commit is still known not to have begun.
990/// It is disarmed immediately before the first WAL append, where the outcome
991/// can become ambiguous and recovery may need the final names.
992struct PreparedRunLinks {
993    links: Vec<(PathBuf, PathBuf)>,
994    armed: bool,
995}
996
997impl PreparedRunLinks {
998    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
999        let mut guard = Self {
1000            links: Vec::with_capacity(spilled.len()),
1001            armed: true,
1002        };
1003        for run in spilled {
1004            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
1005            guard
1006                .links
1007                .push((run.pending_path.clone(), run.final_path.clone()));
1008        }
1009        Ok(guard)
1010    }
1011
1012    fn disarm(&mut self) {
1013        self.armed = false;
1014        for (pending, _) in &self.links {
1015            if let Some(parent) = pending.parent() {
1016                let _ = std::fs::remove_dir_all(parent);
1017            }
1018        }
1019    }
1020}
1021
1022impl Drop for PreparedRunLinks {
1023    fn drop(&mut self) {
1024        if !self.armed {
1025            return;
1026        }
1027        for (pending, final_path) in self.links.iter().rev() {
1028            let _ = std::fs::rename(final_path, pending);
1029        }
1030    }
1031}
1032
1033struct TableApplyBatch {
1034    table_id: u64,
1035    handle: TableHandle,
1036    ops: Vec<crate::txn::StagedOp>,
1037}
1038
1039#[derive(Debug, Clone)]
1040struct TriggerRowImage {
1041    columns: HashMap<u16, Value>,
1042}
1043
1044impl TriggerRowImage {
1045    fn from_row(row: crate::memtable::Row) -> Self {
1046        Self {
1047            columns: row.columns,
1048        }
1049    }
1050
1051    fn from_cells(cells: &[(u16, Value)]) -> Self {
1052        Self {
1053            columns: cells.iter().cloned().collect(),
1054        }
1055    }
1056}
1057
1058#[derive(Debug, Clone)]
1059struct WriteEvent {
1060    table: String,
1061    kind: TriggerEvent,
1062    old: Option<TriggerRowImage>,
1063    new: Option<TriggerRowImage>,
1064    changed_columns: Vec<u16>,
1065    op_indices: Vec<usize>,
1066    put_idx: Option<usize>,
1067    trigger_stack: Vec<String>,
1068}
1069
1070#[derive(Default)]
1071struct TriggerExpansion {
1072    before: Vec<(u64, crate::txn::Staged)>,
1073    before_stacks: Vec<Vec<String>>,
1074    before_external: Vec<ExternalTriggerWrite>,
1075    after: Vec<(u64, crate::txn::Staged)>,
1076    after_stacks: Vec<Vec<String>>,
1077    after_external: Vec<ExternalTriggerWrite>,
1078    ignored_indices: std::collections::BTreeSet<usize>,
1079}
1080
1081#[derive(Clone, PartialEq)]
1082struct TriggerCatalogBinding {
1083    triggers: Vec<TriggerEntry>,
1084    tables: Vec<(String, u64, u64)>,
1085    external_tables: Vec<(String, u64, u64)>,
1086}
1087
1088fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
1089    let mut triggers = catalog
1090        .triggers
1091        .iter()
1092        .filter(|entry| entry.trigger.enabled)
1093        .cloned()
1094        .collect::<Vec<_>>();
1095    if triggers.is_empty() {
1096        return None;
1097    }
1098    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
1099    let mut tables = catalog
1100        .tables
1101        .iter()
1102        .filter(|entry| matches!(entry.state, TableState::Live))
1103        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
1104        .collect::<Vec<_>>();
1105    tables.sort_unstable();
1106    let mut external_tables = catalog
1107        .external_tables
1108        .iter()
1109        .map(|entry| {
1110            (
1111                entry.name.clone(),
1112                entry.created_epoch,
1113                entry.declared_schema.schema_id,
1114            )
1115        })
1116        .collect::<Vec<_>>();
1117    external_tables.sort_unstable();
1118    Some(TriggerCatalogBinding {
1119        triggers,
1120        tables,
1121        external_tables,
1122    })
1123}
1124
1125struct TriggerProgramOutput<'a> {
1126    added: &'a mut Vec<(u64, crate::txn::Staged)>,
1127    added_stacks: &'a mut Vec<Vec<String>>,
1128    added_external: &'a mut Vec<ExternalTriggerWrite>,
1129    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
1130}
1131
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1133enum TriggerProgramOutcome {
1134    Continue,
1135    Ignore,
1136}
1137
1138/// An integrity issue found by [`Database::check`] (spec §16).
1139#[derive(Debug, Clone)]
1140pub struct CheckIssue {
1141    pub table_id: u64,
1142    pub table_name: String,
1143    pub severity: String,
1144    pub description: String,
1145}
1146
1147/// One optimistic authorization snapshot for a complete scored read.
1148#[derive(Debug, Clone)]
1149pub struct AuthorizedReadSnapshot {
1150    pub table: String,
1151    pub table_snapshot: Snapshot,
1152    pub data_generation: u64,
1153    pub security_version: u64,
1154    pub allowed_row_ids: Option<HashSet<RowId>>,
1155}
1156
1157/// Exact table/security generation used by one successful authorized read.
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1159pub struct AuthorizedReadStamp {
1160    pub table_id: u64,
1161    pub schema_id: u64,
1162    pub data_generation: u64,
1163    pub security_version: u64,
1164    pub snapshot: Snapshot,
1165}
1166
1167type RlsCacheKey = (String, u64, u64, String);
1168
1169/// Runtime statistics for the byte-bounded RLS candidate cache.
1170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1171pub struct RlsCacheStats {
1172    pub entries: usize,
1173    pub bytes: usize,
1174    pub hits: u64,
1175    pub misses: u64,
1176    pub evictions: u64,
1177    pub build_nanos: u64,
1178    pub rows_evaluated: u64,
1179}
1180
1181const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
1182const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
1183const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
1184const CDC_MAX_EVENTS: usize = 100_000;
1185const CDC_MAX_ROWS: usize = 1_000_000;
1186const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
1187const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
1188
1189fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
1190    let requested = total.saturating_add(amount);
1191    if requested > CDC_MAX_RETAINED_BYTES {
1192        return Err(MongrelError::ResourceLimitExceeded {
1193            resource,
1194            requested,
1195            limit: CDC_MAX_RETAINED_BYTES,
1196        });
1197    }
1198    *total = requested;
1199    Ok(())
1200}
1201
1202fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
1203    usize::try_from(row.estimated_bytes())
1204        .unwrap_or(usize::MAX)
1205        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
1206}
1207
1208fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
1209    let value_slot = std::mem::size_of::<serde_json::Value>();
1210    row.columns.values().fold(512_usize, |bytes, value| {
1211        let values = match value {
1212            Value::Bytes(values) => values.len(),
1213            Value::Json(values) => values.len(),
1214            Value::Embedding(values) => values.len(),
1215            Value::GeneratedEmbedding(value) => value.vector.len(),
1216            _ => 1,
1217        };
1218        bytes.saturating_add(values.saturating_mul(value_slot))
1219    })
1220}
1221
1222fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
1223    rows.iter().fold(0_usize, |bytes, row| {
1224        bytes.saturating_add(cdc_row_json_bytes(row))
1225    })
1226}
1227
1228#[derive(Default)]
1229struct RlsCache {
1230    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
1231    lru: VecDeque<RlsCacheKey>,
1232    bytes: usize,
1233    hits: u64,
1234    misses: u64,
1235    evictions: u64,
1236    build_nanos: u64,
1237    rows_evaluated: u64,
1238}
1239
1240impl RlsCache {
1241    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
1242        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
1243        if value.is_some() {
1244            self.hits = self.hits.saturating_add(1);
1245            self.touch(key);
1246        } else {
1247            self.misses = self.misses.saturating_add(1);
1248        }
1249        value
1250    }
1251
1252    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
1253        let bytes = key
1254            .0
1255            .len()
1256            .saturating_add(key.3.len())
1257            .saturating_add(
1258                value
1259                    .capacity()
1260                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
1261            )
1262            .saturating_add(std::mem::size_of::<RlsCacheKey>());
1263        if bytes > RLS_CACHE_MAX_BYTES {
1264            return;
1265        }
1266        if let Some((_, old_bytes)) = self.entries.remove(&key) {
1267            self.bytes = self.bytes.saturating_sub(old_bytes);
1268        }
1269        self.lru.retain(|candidate| candidate != &key);
1270        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
1271            let Some(oldest) = self.lru.pop_front() else {
1272                break;
1273            };
1274            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
1275                self.bytes = self.bytes.saturating_sub(old_bytes);
1276                self.evictions = self.evictions.saturating_add(1);
1277            }
1278        }
1279        self.bytes = self.bytes.saturating_add(bytes);
1280        self.lru.push_back(key.clone());
1281        self.entries.insert(key, (value, bytes));
1282    }
1283
1284    fn touch(&mut self, key: &RlsCacheKey) {
1285        self.lru.retain(|candidate| candidate != key);
1286        self.lru.push_back(key.clone());
1287    }
1288
1289    fn stats(&self) -> RlsCacheStats {
1290        RlsCacheStats {
1291            entries: self.entries.len(),
1292            bytes: self.bytes,
1293            hits: self.hits,
1294            misses: self.misses,
1295            evictions: self.evictions,
1296            build_nanos: self.build_nanos,
1297            rows_evaluated: self.rows_evaluated,
1298        }
1299    }
1300}
1301
1302/// Mounted table with immutable, structurally shared scored-read generations.
1303#[derive(Clone)]
1304pub struct TableHandle {
1305    inner: TableHandleInner,
1306    generation_metrics: Arc<TableGenerationMetrics>,
1307}
1308
1309#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1310pub struct TableGenerationStats {
1311    pub active_read_generations: usize,
1312    pub max_live_read_generations: usize,
1313    pub cow_clone_count: u64,
1314    pub cow_clone_nanos: u64,
1315    pub estimated_cow_clone_bytes: u64,
1316    pub writer_wait_nanos: u64,
1317}
1318
1319#[derive(Default)]
1320#[doc(hidden)]
1321pub struct TableGenerationMetrics {
1322    active_read_generations: AtomicUsize,
1323    max_live_read_generations: AtomicUsize,
1324    cow_clone_count: AtomicU64,
1325    cow_clone_nanos: AtomicU64,
1326    estimated_cow_clone_bytes: AtomicU64,
1327    writer_wait_nanos: AtomicU64,
1328}
1329
1330impl TableGenerationMetrics {
1331    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
1332        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
1333        self.max_live_read_generations
1334            .fetch_max(active, Ordering::Relaxed);
1335        Arc::new(TableReadGeneration {
1336            table,
1337            metrics: Arc::clone(self),
1338        })
1339    }
1340
1341    fn stats(&self) -> TableGenerationStats {
1342        TableGenerationStats {
1343            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
1344            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
1345            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
1346            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
1347            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
1348            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
1349        }
1350    }
1351}
1352
1353/// Immutable, structurally shared snapshot used by scored readers.
1354pub struct TableReadGeneration {
1355    table: Table,
1356    metrics: Arc<TableGenerationMetrics>,
1357}
1358
1359impl std::ops::Deref for TableReadGeneration {
1360    type Target = Table;
1361
1362    fn deref(&self) -> &Self::Target {
1363        &self.table
1364    }
1365}
1366
1367impl Drop for TableReadGeneration {
1368    fn drop(&mut self) {
1369        self.metrics
1370            .active_read_generations
1371            .fetch_sub(1, Ordering::Relaxed);
1372    }
1373}
1374
1375#[derive(Clone)]
1376enum TableHandleInner {
1377    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
1378    Direct(Arc<Mutex<Table>>),
1379}
1380
1381pub enum TableGuard<'a> {
1382    CopyOnWrite {
1383        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
1384        metrics: Arc<TableGenerationMetrics>,
1385    },
1386    Direct {
1387        table: parking_lot::MutexGuard<'a, Table>,
1388    },
1389}
1390
1391impl TableHandle {
1392    fn new(table: Table) -> Self {
1393        Self {
1394            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
1395            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1396        }
1397    }
1398
1399    pub fn from_table(table: Table) -> Self {
1400        Self::new(table)
1401    }
1402
1403    pub fn lock(&self) -> TableGuard<'_> {
1404        let started = std::time::Instant::now();
1405        let guard = match &self.inner {
1406            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
1407                table: table.write(),
1408                metrics: Arc::clone(&self.generation_metrics),
1409            },
1410            TableHandleInner::Direct(table) => TableGuard::Direct {
1411                table: table.lock(),
1412            },
1413        };
1414        self.generation_metrics.writer_wait_nanos.fetch_add(
1415            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1416            Ordering::Relaxed,
1417        );
1418        guard
1419    }
1420
1421    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
1422        let started = std::time::Instant::now();
1423        let guard = match &self.inner {
1424            TableHandleInner::CopyOnWrite(table) => {
1425                table
1426                    .try_write_for(timeout)
1427                    .map(|table| TableGuard::CopyOnWrite {
1428                        table,
1429                        metrics: Arc::clone(&self.generation_metrics),
1430                    })
1431            }
1432            TableHandleInner::Direct(table) => table
1433                .try_lock_for(timeout)
1434                .map(|table| TableGuard::Direct { table }),
1435        };
1436        self.generation_metrics.writer_wait_nanos.fetch_add(
1437            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1438            Ordering::Relaxed,
1439        );
1440        guard
1441    }
1442
1443    pub fn ptr_eq(&self, other: &Self) -> bool {
1444        match (&self.inner, &other.inner) {
1445            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1446                Arc::ptr_eq(left, right)
1447            }
1448            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1449                Arc::ptr_eq(left, right)
1450            }
1451            _ => false,
1452        }
1453    }
1454
1455    pub fn read_generation_with_context(
1456        &self,
1457        context: Option<&crate::query::AiExecutionContext>,
1458    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1459        let mut table = if let Some(context) = context {
1460            loop {
1461                context.checkpoint()?;
1462                let wait = context
1463                    .remaining_duration()
1464                    .unwrap_or(std::time::Duration::from_millis(5))
1465                    .min(std::time::Duration::from_millis(5));
1466                if let Some(table) = self.try_lock_for(wait) {
1467                    break table;
1468                }
1469            }
1470        } else {
1471            self.lock()
1472        };
1473        let snapshot = table.snapshot();
1474        let generation = table.clone_read_generation()?;
1475        Ok((self.generation_metrics.activate(generation), snapshot))
1476    }
1477
1478    pub fn generation_stats(&self) -> TableGenerationStats {
1479        self.generation_metrics.stats()
1480    }
1481}
1482
1483impl From<Arc<Mutex<Table>>> for TableHandle {
1484    fn from(table: Arc<Mutex<Table>>) -> Self {
1485        Self {
1486            inner: TableHandleInner::Direct(table),
1487            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1488        }
1489    }
1490}
1491
1492impl std::ops::Deref for TableGuard<'_> {
1493    type Target = Table;
1494
1495    fn deref(&self) -> &Self::Target {
1496        match self {
1497            Self::CopyOnWrite { table, .. } => table.as_ref(),
1498            Self::Direct { table } => table,
1499        }
1500    }
1501}
1502
1503impl std::ops::DerefMut for TableGuard<'_> {
1504    fn deref_mut(&mut self) -> &mut Self::Target {
1505        match self {
1506            Self::CopyOnWrite { table, metrics } => {
1507                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1508                    let estimated_bytes = table.estimated_clone_bytes();
1509                    let started = std::time::Instant::now();
1510                    let table = Arc::make_mut(table);
1511                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1512                    metrics.cow_clone_nanos.fetch_add(
1513                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1514                        Ordering::Relaxed,
1515                    );
1516                    metrics
1517                        .estimated_cow_clone_bytes
1518                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1519                    table
1520                } else {
1521                    Arc::make_mut(table)
1522                }
1523            }
1524            Self::Direct { table } => table,
1525        }
1526    }
1527}
1528
1529#[derive(Clone, Debug)]
1530pub struct ReadAuthorization {
1531    pub operation: crate::auth::ColumnOperation,
1532    pub columns: Vec<u16>,
1533    pub permissions: Vec<crate::auth::Permission>,
1534}
1535
1536#[derive(Default, Debug)]
1537struct TableWritePermissionNeeds {
1538    insert: bool,
1539    insert_columns: Vec<u16>,
1540    update: bool,
1541    update_columns: Vec<u16>,
1542    delete: bool,
1543    truncate: bool,
1544}
1545
1546#[cfg(test)]
1547thread_local! {
1548    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1549    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1550    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1551    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1552    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1553    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1554}
1555
1556fn summarize_write_permissions(
1557    staging: &[(u64, crate::txn::Staged)],
1558) -> HashMap<u64, TableWritePermissionNeeds> {
1559    use crate::txn::Staged;
1560
1561    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1562    for (table_id, operation) in staging {
1563        let table = needs.entry(*table_id).or_default();
1564        match operation {
1565            Staged::Put(cells) => {
1566                table.insert = true;
1567                table
1568                    .insert_columns
1569                    .extend(cells.iter().map(|(column, _)| *column));
1570            }
1571            Staged::Update {
1572                changed_columns, ..
1573            } => {
1574                table.update = true;
1575                table.update_columns.extend(changed_columns);
1576            }
1577            Staged::Delete(_) => table.delete = true,
1578            Staged::Truncate => table.truncate = true,
1579        }
1580    }
1581    for table in needs.values_mut() {
1582        table.insert_columns.sort_unstable();
1583        table.insert_columns.dedup();
1584        table.update_columns.sort_unstable();
1585        table.update_columns.dedup();
1586    }
1587    needs
1588}
1589
1590struct SecurityCoordinator {
1591    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1592    gate: RwLock<()>,
1593    version: AtomicU64,
1594}
1595
1596fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1597    static COORDINATORS: std::sync::OnceLock<
1598        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1599    > = std::sync::OnceLock::new();
1600
1601    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1602    let mut coordinators = COORDINATORS
1603        .get_or_init(|| Mutex::new(HashMap::new()))
1604        .lock();
1605    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1606    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1607        return coordinator;
1608    }
1609    let coordinator = Arc::new(SecurityCoordinator {
1610        gate: RwLock::new(()),
1611        version: AtomicU64::new(version),
1612    });
1613    coordinators.insert(root, Arc::downgrade(&coordinator));
1614    coordinator
1615}
1616
1617pub fn lock_table_with_context<'a>(
1618    handle: &'a TableHandle,
1619    context: Option<&crate::query::AiExecutionContext>,
1620) -> Result<TableGuard<'a>> {
1621    let Some(context) = context else {
1622        return Ok(handle.lock());
1623    };
1624    loop {
1625        context.checkpoint()?;
1626        let wait = context
1627            .remaining_duration()
1628            .unwrap_or(std::time::Duration::from_millis(5))
1629            .min(std::time::Duration::from_millis(5));
1630        if let Some(guard) = handle.try_lock_for(wait) {
1631            return Ok(guard);
1632        }
1633    }
1634}
1635
1636/// Knobs for [`Database::open_with_options`].
1637///
1638/// All fields default to the same values the convenience
1639/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1640/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1641#[derive(Clone, Debug, Default)]
1642pub struct OpenOptions {
1643    /// Maximum time, in milliseconds, to wait for the cross-process database
1644    /// lock (`_meta/.lock`) before failing with `MongrelError::DatabaseLocked`.
1645    ///
1646    /// `0` (the default) preserves the historical fail-fast semantics: a
1647    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1648    /// `busy_timeout` semantics kick in once this is non-zero — the open
1649    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1650    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1651    /// point the open returns the same typed lock error as the fail-fast path.
1652    ///
1653    /// Only the cross-process lock is affected. Mounted tables, page-cache
1654    /// misses, and WAL appends already serialize through in-process locks
1655    /// that handle their own contention. A second independent open in the
1656    /// same process always returns `DatabaseLocked` immediately; share the
1657    /// existing `Arc<Database>` instead.
1658    pub lock_timeout_ms: u32,
1659    /// Total bytes the storage core's [`crate::memory::MemoryGovernor`] may
1660    /// hand out across every memory class (S1E-003). `None` (the default)
1661    /// uses [`DEFAULT_MEMORY_BUDGET_BYTES`]. The governor is a reservation
1662    /// accounting layer, not an allocation: this is a cap, not a preallocation.
1663    pub memory_budget_bytes: Option<u64>,
1664    /// Total bytes of live spill files the core's
1665    /// [`crate::spill::SpillManager`] allows across every query (S1E-004).
1666    /// `None` (the default) uses [`DEFAULT_TEMP_DISK_BUDGET_BYTES`]. Again a
1667    /// cap, not a preallocation.
1668    pub temp_disk_budget_bytes: Option<u64>,
1669    /// Open the database through the special offline-validation API (spec
1670    /// section 5.3): any [`crate::storage_mode::StorageMode`] — including
1671    /// `ClusterReplica`, which every normal open path rejects — is opened
1672    /// **read-only** so a backup validator can inspect it. The opened core
1673    /// rejects every write with [`MongrelError::ReadOnlyReplica`]. This is the
1674    /// only way to open a cluster replica outside the cluster node runtime.
1675    pub offline_validation: bool,
1676}
1677
1678impl OpenOptions {
1679    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1680    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1681    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1682        self.lock_timeout_ms = ms;
1683        self
1684    }
1685
1686    /// Set [`OpenOptions::memory_budget_bytes`].
1687    pub fn with_memory_budget_bytes(mut self, bytes: u64) -> Self {
1688        self.memory_budget_bytes = Some(bytes);
1689        self
1690    }
1691
1692    /// Set [`OpenOptions::temp_disk_budget_bytes`].
1693    pub fn with_temp_disk_budget_bytes(mut self, bytes: u64) -> Self {
1694        self.temp_disk_budget_bytes = Some(bytes);
1695        self
1696    }
1697
1698    /// Set [`OpenOptions::offline_validation`]. `true` opens any storage mode
1699    /// read-only (the offline backup-validator API of spec section 5.3).
1700    pub fn with_offline_validation(mut self, offline_validation: bool) -> Self {
1701        self.offline_validation = offline_validation;
1702        self
1703    }
1704}
1705
1706/// How an open/create path treats the durable storage-mode marker (spec
1707/// section 5.3, Stage 2E). Threaded from the public constructors through the
1708/// open chain into [`Database::finish_open`].
1709#[derive(Debug, Clone)]
1710pub(crate) enum OpenModeGate {
1711    /// Normal embedded/server open: `ClusterReplica` markers are rejected with
1712    /// [`crate::storage_mode::StorageModeError::ClusterReplicaRequiresClusterRuntime`].
1713    Normal,
1714    /// Process-local shared core. Authentication binds to each issued handle,
1715    /// so core recovery and table mounting proceed without a caller principal.
1716    SharedCore,
1717    /// Offline backup validator: any mode opens, forced read-only.
1718    OfflineValidation,
1719    /// Cluster node runtime: the marker must exist and equal this
1720    /// `ClusterReplica` identity; the core opens read-only for user paths (all
1721    /// writes arrive through the replicated apply path).
1722    ClusterRuntime {
1723        /// Expected owning cluster.
1724        cluster_id: mongreldb_types::ids::ClusterId,
1725        /// Expected owning node.
1726        node_id: mongreldb_types::ids::NodeId,
1727        /// Expected replicated database.
1728        database_id: mongreldb_types::ids::DatabaseId,
1729    },
1730    /// Fresh create: no marker may exist yet; this mode is written.
1731    Create(crate::storage_mode::StorageMode),
1732}
1733
1734/// Default node-level memory budget (S1E-003): 1 GiB. Comfortably covers the
1735/// two 64 MiB caches with headroom for query execution, result buffering, and
1736/// maintenance reservations.
1737pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
1738
1739/// Default node-level temporary-disk (spill) budget (S1E-004): 4 GiB of live
1740/// spill files across every query on the core.
1741pub const DEFAULT_TEMP_DISK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
1742
1743/// Resolved node-level resource budgets for one storage core
1744/// (S1E-003/S1E-004), threaded from [`OpenOptions`] through the open chain
1745/// into [`Database::finish_open`].
1746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1747pub(crate) struct CoreResourceConfig {
1748    pub memory_budget_bytes: u64,
1749    pub temp_disk_budget_bytes: u64,
1750}
1751
1752impl Default for CoreResourceConfig {
1753    fn default() -> Self {
1754        Self {
1755            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
1756            temp_disk_budget_bytes: DEFAULT_TEMP_DISK_BUDGET_BYTES,
1757        }
1758    }
1759}
1760
1761impl CoreResourceConfig {
1762    fn from_options(options: &OpenOptions) -> Result<Self> {
1763        let config = Self {
1764            memory_budget_bytes: options
1765                .memory_budget_bytes
1766                .unwrap_or(DEFAULT_MEMORY_BUDGET_BYTES),
1767            temp_disk_budget_bytes: options
1768                .temp_disk_budget_bytes
1769                .unwrap_or(DEFAULT_TEMP_DISK_BUDGET_BYTES),
1770        };
1771        if config.memory_budget_bytes == 0 {
1772            return Err(MongrelError::InvalidArgument(
1773                "memory_budget_bytes must be nonzero".into(),
1774            ));
1775        }
1776        if config.temp_disk_budget_bytes == 0 {
1777            return Err(MongrelError::InvalidArgument(
1778                "temp_disk_budget_bytes must be nonzero".into(),
1779            ));
1780        }
1781        Ok(config)
1782    }
1783}
1784
1785/// Per-table version-retention pin diagnostics (S1C-004): one mounted table's
1786/// active pin sources as reported by [`crate::engine::Table::version_pins_report`].
1787#[derive(Debug, Clone)]
1788pub struct TablePinsReport {
1789    /// The mounted table's id.
1790    pub table_id: u64,
1791    /// The mounted table's catalog name.
1792    pub table: String,
1793    /// Every active version-retention pin source on the table.
1794    pub pins: crate::retention::PinsReport,
1795}
1796
1797/// The shared storage core (spec §10.1, S1A-001): one catalog, one epoch
1798/// clock, shared caches, a shared WAL, and a live map of name → `Arc<Table>`.
1799///
1800/// `DatabaseCore` owns every storage resource — the durable root, the
1801/// exclusive lease, the catalog, the commit log, the epoch authority, the
1802/// transaction state, the mounted tables, and the page caches — plus the
1803/// lifecycle state machine (S1A-004). It is shared through an `Arc`: the
1804/// exclusive [`Database`] owner holds one reference, and every
1805/// [`crate::handle::DatabaseHandle`] issued by
1806/// [`crate::manager::DatabaseManager`] holds another. The core deliberately
1807/// does **not** store one mutable "current principal" (spec §4.6): per-caller
1808/// identity lives on the handle types, not inside shared storage state.
1809pub struct DatabaseCore {
1810    root: PathBuf,
1811    durable_root: Arc<crate::durable_file::DurableRoot>,
1812    /// Lifecycle state machine (spec §10.1, S1A-004). Shared through an `Arc`
1813    /// so [`crate::core::OperationGuard`]s are `'static` and thread-safe.
1814    lifecycle: Arc<crate::core::LifecycleController>,
1815    /// Set by [`crate::manager::DatabaseManager`] when this core backs shared
1816    /// handles: lets `shutdown()` transition the registry entry through
1817    /// `Closing` to removal. `None` for exclusively-owned cores.
1818    registry: std::sync::OnceLock<crate::manager::CoreRegistration>,
1819    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1820    read_only: bool,
1821    catalog: RwLock<Catalog>,
1822    security_coordinator: Arc<SecurityCoordinator>,
1823    security_catalog_disk_reads: AtomicU64,
1824    rls_cache: Mutex<RlsCache>,
1825    epoch: Arc<EpochAuthority>,
1826    snapshots: Arc<SnapshotRegistry>,
1827    /// S1E-003: the node-level memory governor for this core. Exactly one per
1828    /// core; both caches below hold reservations under it and are registered
1829    /// as reclaimable subsystems it can evict under pressure (escalation
1830    /// step 2).
1831    memory_governor: crate::memory::MemoryGovernor,
1832    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1833    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1834    /// S1E-004: the node-level spill manager, rooted at `<db-root>/temp/spill`
1835    /// and sealed with the database meta DEK when encryption is enabled. Its
1836    /// startup sweep ran during open; per-query sessions draw from it.
1837    spill_manager: crate::spill::SpillManager,
1838    /// S1E-002: workload resource groups for admission (concurrency, memory,
1839    /// temp disk, work units). Seeded with class defaults at open; operators
1840    /// reconfigure through [`Database::resource_groups`].
1841    resource_groups: crate::resource::ResourceGroupRegistry,
1842    /// Optional pluggable embedding providers (local models / registered
1843    /// backends). Empty by default — dense ANN uses application-supplied
1844    /// vectors; sparse retrieval needs no provider.
1845    embedding_providers: crate::embedding::EmbeddingProviderRegistry,
1846    /// Authenticated service-principal definitions for shared-handle open
1847    /// (P0.1). Authority is stored here and re-resolved live per operation;
1848    /// callers cannot supply their own permission vectors on attach.
1849    pub(crate) service_principals: crate::service_principal::ServicePrincipalStore,
1850    /// S1F-002: the durable job registry (sibling `JOBS` file). Exactly one
1851    /// per core; jobs recovered mid-`Running` by a crash come back `Paused`.
1852    job_registry: Arc<crate::jobs::JobRegistry>,
1853    commit_lock: Arc<Mutex<()>>,
1854    kms_rotation_lock: Mutex<()>,
1855    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1856    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1857    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1858    /// writes also land in this one WAL (B1 — one WAL per database).
1859    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1860    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1861    /// in P2.7; here it just needs to be unique within an open. Shared with
1862    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1863    next_txn_id: Arc<Mutex<u64>>,
1864    tables: RwLock<HashMap<u64, TableHandle>>,
1865    kek: Option<Arc<crate::encryption::Kek>>,
1866    /// Serializes DDL (create/drop table); data commits serialize through
1867    /// `commit_lock` shared via `SharedCtx`.
1868    ddl_lock: Mutex<()>,
1869    meta_dek: Option<[u8; META_DEK_LEN]>,
1870    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1871    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1872    spill_threshold: std::sync::atomic::AtomicU64,
1873    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1874    /// detection (spec §9.2).
1875    conflicts: crate::txn::ConflictIndex,
1876    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1877    /// pruning (spec §9.2, review fix #12).
1878    active_txns: crate::txn::ActiveTxns,
1879    /// S1B-003: the core's key/predicate lock manager. One per core; commits
1880    /// acquire unique-constraint key claims, FK parent-protection holds,
1881    /// sequence-allocation barriers, and the DML Shared hold on the schema
1882    /// barrier (DDL takes it Exclusive), all released when the transaction
1883    /// ends.
1884    lock_manager: Arc<crate::locks::LockManager>,
1885    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1886    /// Shared with mounted tables so a single-table commit also honors poison.
1887    poisoned: Arc<std::sync::atomic::AtomicBool>,
1888    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1889    /// but defers the fsync to one leader here, so concurrent commits share a
1890    /// single fsync (spec §9.3). Shared with mounted tables.
1891    group: Arc<crate::txn::GroupCommit>,
1892    /// FND-004 (spec §9.4): configured commit authority. Standalone cores bind
1893    /// `StandaloneCommitLog`; cluster runtime replaces it with the tablet
1894    /// group's `RaftCommitLog` after group construction.
1895    commit_log: RwLock<Arc<dyn mongreldb_log::CommitLog>>,
1896    /// Standalone WAL adapter used only by local standalone mutation paths.
1897    /// Cluster replicas reject those paths and receive mutations through
1898    /// committed apply.
1899    standalone_commit_log: Arc<crate::commit_log::StandaloneCommitLog>,
1900    /// The node's single HLC timestamp authority (spec §4.1, §8.2; ADR-0003).
1901    /// Transaction read timestamps are captured at `begin`, and commit
1902    /// timestamps are allocated here under the sequencer lock (strictly after
1903    /// every participant read/write timestamp). `Epoch` remains the
1904    /// reader-visibility counter during the dual-model migration.
1905    hlc: Arc<mongreldb_types::hlc::HlcClock>,
1906    /// S1B-005: durable idempotency ledger for keyed remote writes, persisted
1907    /// in the sibling `TXN_IDEMPOTENCY` file (mirroring `jobs.rs`'s `JOBS`).
1908    idempotency: crate::txn::IdempotencyLedger,
1909    /// Per-open epoch → commit-timestamp ledger backing
1910    /// [`Database::commit_ts_for_epoch`]. Commits sealed within this open
1911    /// record the exact receipt `HlcTimestamp`; commits recovered from the
1912    /// durable `Op::CommitTimestamp` WAL ledger at open reconstruct the
1913    /// physical component only (`logical`/`node_tiebreaker` are 0 — the
1914    /// ledger byte format stores micros). Bounded to the newest
1915    /// [`COMMIT_TS_LEDGER_CAP`] epochs.
1916    commit_ts_ledger: Mutex<std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp>>,
1917    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1918    /// live spill's pending dir (review fix #14, spec §6.4).
1919    active_spills: Arc<crate::retention::ActiveSpills>,
1920    /// A write lock captures a consistent bootstrap image; transaction commits
1921    /// hold a read lock across spill preparation, WAL append, and publish.
1922    replication_barrier: parking_lot::RwLock<()>,
1923    /// Number of rotated WAL segments retained for lagging followers.
1924    replication_wal_retention_segments: AtomicUsize,
1925    /// Live immutable run files used by online backups or scored read
1926    /// generations. GC cannot unlink them until every owning guard drops.
1927    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1928    /// Test-only barrier invoked after a transaction writes its spill runs but
1929    /// before the sequencer/publish, so tests can race `gc()` against an
1930    /// in-flight spill. `None` in production.
1931    #[doc(hidden)]
1932    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1933    /// Test seam after the security read gate is held and before WAL append.
1934    #[doc(hidden)]
1935    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1936    /// Test seam after transaction preparation and before catalog generation
1937    /// validation under the commit sequencer.
1938    #[doc(hidden)]
1939    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1940    /// Test seam after a backup boundary is captured and before pinned runs are
1941    /// copied. Lets tests compact+GC the source at the worst possible moment.
1942    #[doc(hidden)]
1943    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1944    /// Test seam invoked after each successful FK parent-protection lock
1945    /// acquisition inside constraint validation, so tests can rendezvous two
1946    /// committing transactions into a deterministic wait-for cycle. Behind an
1947    /// `Arc` so firing never holds the slot's mutex — concurrent commits (and
1948    /// a hook that itself parks) must not serialize on it.
1949    #[doc(hidden)]
1950    fk_lock_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
1951    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1952    trigger_recursive: AtomicBool,
1953    trigger_max_depth: AtomicU32,
1954    trigger_max_loop_iterations: AtomicU32,
1955    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1956    /// is reconstructed from the WAL by [`Database::change_events_since`].
1957    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1958    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1959    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1960    change_wake: tokio::sync::broadcast::Sender<()>,
1961    /// Final field so every storage resource drops before the exclusive lease.
1962    /// Behind a `Mutex<Option<_>>` so `shutdown()` can release the file lock
1963    /// (spec §10.1, S1A-004 step 7) while handles still reference the core.
1964    _lock: Mutex<Option<ExclusiveDatabaseLease>>,
1965}
1966
1967/// The exclusive public owner of one [`DatabaseCore`] (spec §10.1, S1A-001).
1968///
1969/// `Database::open`/`create*` build exactly one core for a root and reject any
1970/// other core — exclusive or shared — for the same root (spec §2.6). All
1971/// storage state lives on the core; this type adds the owner-bound identity
1972/// context (the cached principal and the shared auth state used by the
1973/// embedded enforcement path). Method calls and field reads transparently
1974/// reach the core through `Deref`, so existing `Database` call sites are
1975/// unchanged.
1976pub struct Database {
1977    core: Arc<DatabaseCore>,
1978    /// The authenticated principal for this owner. `None` on databases
1979    /// opened without credentials (the default — `require_auth = false`),
1980    /// `Some` on credentialed opens. Consulted by every enforcement point
1981    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1982    /// because the access pattern is read-heavy: every `require()` call
1983    /// reads the principal, while writes happen only at open, `enable_auth`,
1984    /// and `refresh_principal`. This matches the engine's existing use of
1985    /// `RwLock` for `catalog` and `tables`.
1986    /// See `docs/15-credential-enforcement.md`.
1987    principal: RwLock<Option<crate::auth::Principal>>,
1988    /// Shared, cloneable handle to the auth state (require_auth flag from the
1989    /// catalog + the principal). Cloned into every mounted `Table` so the
1990    /// Table layer can enforce permissions without holding a reference back
1991    /// to `Database` (which would create a cycle). `AuthState` is already
1992    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1993    auth_state: crate::auth_state::AuthState,
1994    /// `true` when this value is a manager-issued facade over a shared core
1995    /// (Stage 1A): dropping it has no storage side effects, `shutdown()`
1996    /// rejects, and auth-mode transitions are refused so one handle cannot
1997    /// flip the shared core into an enforcement mode other handles cannot
1998    /// observe (fail closed; per-handle enforcement lands with Stage 1D
1999    /// sessions).
2000    shared: bool,
2001}
2002
2003impl std::ops::Deref for Database {
2004    type Target = DatabaseCore;
2005
2006    fn deref(&self) -> &DatabaseCore {
2007        &self.core
2008    }
2009}
2010
2011struct RunPins {
2012    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
2013    runs: Vec<(u64, u128)>,
2014}
2015
2016/// RAII proof that one transaction's lock holds release when the scope that
2017/// acquired them exits (S1B-004 step 12). Used by the commit path (covers
2018/// success, validation failure, and poison alike) and by DDL entry points
2019/// holding the Exclusive schema barrier.
2020struct TxnLockGuard<'a> {
2021    locks: &'a crate::locks::LockManager,
2022    txn_id: u64,
2023}
2024
2025impl Drop for TxnLockGuard<'_> {
2026    fn drop(&mut self) {
2027        self.locks.release_all(self.txn_id);
2028    }
2029}
2030
2031struct BackupFilePins {
2032    root: PathBuf,
2033}
2034
2035struct PendingTableDir {
2036    path: PathBuf,
2037    armed: bool,
2038}
2039
2040impl PendingTableDir {
2041    fn new(path: PathBuf) -> Self {
2042        Self { path, armed: true }
2043    }
2044
2045    fn disarm(&mut self) {
2046        self.armed = false;
2047    }
2048}
2049
2050impl Drop for PendingTableDir {
2051    fn drop(&mut self) {
2052        if self.armed {
2053            let _ = std::fs::remove_dir_all(&self.path);
2054        }
2055    }
2056}
2057
2058impl Drop for BackupFilePins {
2059    fn drop(&mut self) {
2060        let _ = std::fs::remove_dir_all(&self.root);
2061    }
2062}
2063
2064impl Drop for RunPins {
2065    fn drop(&mut self) {
2066        let mut pins = self.pins.lock();
2067        for run in &self.runs {
2068            if let Some(count) = pins.get_mut(run) {
2069                *count -= 1;
2070                if *count == 0 {
2071                    pins.remove(run);
2072                }
2073            }
2074        }
2075    }
2076}
2077
2078/// A durable data-change event reconstructed from committed WAL records, or an
2079/// ephemeral SQL `NOTIFY` event when `id` is `None`.
2080#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2081pub struct ChangeEvent {
2082    pub id: Option<String>,
2083    pub channel: String,
2084    pub table_id: Option<u64>,
2085    pub table: String,
2086    pub op: String,
2087    pub epoch: u64,
2088    pub txn_id: Option<u64>,
2089    pub message: Option<String>,
2090    pub data: Option<serde_json::Value>,
2091}
2092
2093#[derive(Debug, Clone)]
2094pub struct CdcBatch {
2095    pub events: Vec<ChangeEvent>,
2096    pub current_epoch: u64,
2097    pub earliest_epoch: Option<u64>,
2098    pub gap: bool,
2099}
2100
2101/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
2102/// (root, epoch, table count, encryption/auth state) without requiring every
2103/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
2104/// The raw field types carry locks, trait objects, and channels that have no
2105/// useful `Debug` output, so a hand-written impl is clearer than peppering
2106/// `#[allow(dead_code)]` skip attributes across two dozen fields.
2107impl std::fmt::Debug for Database {
2108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2109        let cat = self.catalog.read();
2110        let principal_guard = self.principal.read();
2111        let principal: &str = principal_guard
2112            .as_ref()
2113            .map(|p| p.username.as_str())
2114            .unwrap_or("<none>");
2115        f.debug_struct("Database")
2116            .field("root", &self.root)
2117            .field("db_epoch", &cat.db_epoch)
2118            .field("open_generation", &"sidecar")
2119            .field("tables", &cat.tables.len())
2120            .field("visible_epoch", &self.epoch.visible().0)
2121            .field("encrypted", &self.kek.is_some())
2122            .field("require_auth", &cat.require_auth)
2123            .field("principal", &principal)
2124            .finish()
2125    }
2126}
2127
2128/// Manual `Debug` for `DatabaseCore`, mirroring `Database`'s (see above). The
2129/// core has no cached principal by design (spec §4.6).
2130impl std::fmt::Debug for DatabaseCore {
2131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2132        let cat = self.catalog.read();
2133        f.debug_struct("DatabaseCore")
2134            .field("root", &self.root)
2135            .field("lifecycle", &self.lifecycle.state())
2136            .field("db_epoch", &cat.db_epoch)
2137            .field("tables", &cat.tables.len())
2138            .field("visible_epoch", &self.epoch.visible().0)
2139            .field("encrypted", &self.kek.is_some())
2140            .field("require_auth", &cat.require_auth)
2141            .finish()
2142    }
2143}
2144
2145impl DatabaseCore {
2146    fn ensure_owner_process(&self) -> Result<()> {
2147        let current_pid = std::process::id();
2148        let owner_pid = self
2149            ._lock
2150            .lock()
2151            .as_ref()
2152            .map(|lease| lease.owner_pid)
2153            .unwrap_or(current_pid);
2154        if current_pid == owner_pid {
2155            Ok(())
2156        } else {
2157            Err(MongrelError::ForkedProcess {
2158                owner_pid,
2159                current_pid,
2160            })
2161        }
2162    }
2163
2164    /// The canonical filesystem root this core was opened at.
2165    pub fn root(&self) -> &Path {
2166        self.durable_root.canonical_path()
2167    }
2168
2169    /// The stable directory-handle identity of this core's root (S1A-003).
2170    pub fn file_identity(&self) -> Result<crate::core::DatabaseFileIdentity> {
2171        crate::core::DatabaseFileIdentity::from_durable_root(&self.durable_root)
2172    }
2173
2174    /// The current lifecycle state (S1A-004).
2175    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2176        self.lifecycle.state()
2177    }
2178
2179    /// Whether this core currently admits new operations.
2180    pub fn is_open(&self) -> bool {
2181        self.lifecycle.is_open()
2182    }
2183
2184    /// Admit one operation against this core (S1A-004: "every operation holds
2185    /// an `OperationGuard`"). Rejected unless the core is
2186    /// [`crate::core::LifecycleState::Open`].
2187    pub fn operation_guard(self: &Arc<Self>) -> Result<crate::core::OperationGuard> {
2188        self.lifecycle.begin_operation()
2189    }
2190
2191    /// Register this core with the [`crate::manager::DatabaseManager`] that
2192    /// initialized it (S1A-002). Called once, before the first handle is
2193    /// handed out.
2194    pub(crate) fn set_registry(
2195        &self,
2196        registration: crate::manager::CoreRegistration,
2197    ) -> Result<()> {
2198        self.registry
2199            .set(registration)
2200            .map_err(|_| MongrelError::Conflict("database core is already registry-bound".into()))
2201    }
2202
2203    /// Ordered core shutdown (spec §10.1, S1A-004):
2204    ///
2205    /// 1. Transition `Open` → `Draining`.
2206    /// 2. Reject new sessions and writes (new [`Self::operation_guard`]s fail
2207    ///    from this point).
2208    /// 3. Cancel non-commit-critical queries — a no-op in Stage 1A: the
2209    ///    embedded core has no central query registry yet (Stage 1D adds one),
2210    ///    and every in-flight operation is treated as commit-critical and
2211    ///    drained rather than cancelled.
2212    /// 4. Wait for in-flight operations, up to `drain_deadline`. On timeout
2213    ///    the core stays `Draining` and a later call may resume the shutdown.
2214    /// 5. Sync required durable state (group-sync the shared WAL).
2215    /// 6. Stop workers — the Stage 1A core runs no background worker set.
2216    /// 7. Release the file lock (and the process open-registry entries).
2217    /// 8. Mark `Closed`.
2218    ///
2219    /// Repeated calls after `Closed` return `Ok(())`. Dropping one handle has
2220    /// no storage side effects; only this method (or the last `Arc` drop)
2221    /// closes storage.
2222    pub fn shutdown(self: &Arc<Self>, drain_deadline: std::time::Duration) -> Result<()> {
2223        self.ensure_owner_process()?;
2224        let initiated = self.lifecycle.begin_shutdown();
2225        if !initiated {
2226            match self.lifecycle.state() {
2227                crate::core::LifecycleState::Closed => return Ok(()),
2228                // A concurrent or timed-out shutdown left the core draining;
2229                // join it and drive the remaining steps.
2230                crate::core::LifecycleState::Draining => {}
2231                state => {
2232                    return Err(MongrelError::Conflict(format!(
2233                        "database core cannot shut down from lifecycle state {state}"
2234                    )))
2235                }
2236            }
2237        }
2238        if let Some(registration) = self.registry.get() {
2239            registration
2240                .manager
2241                .mark_closing(&registration.identity, self);
2242        }
2243        self.lifecycle.wait_drained(drain_deadline)?;
2244        let sync = self.shared_wal.lock().group_sync().map(|_| ());
2245        self.lifecycle.mark_closing();
2246        let lease = self._lock.lock().take();
2247        drop(lease);
2248        if let Some(registration) = self.registry.get() {
2249            registration
2250                .manager
2251                .entry_closed(&registration.identity, self);
2252        }
2253        self.lifecycle.mark_closed();
2254        sync
2255    }
2256}
2257
2258impl Database {
2259    pub fn open_metrics() -> DatabaseOpenMetrics {
2260        DatabaseOpenMetrics {
2261            lock_waits: DATABASE_OPEN_WAIT_COUNT.load(Ordering::Relaxed),
2262            failures: DATABASE_OPEN_FAILURE_COUNT.load(Ordering::Relaxed),
2263        }
2264    }
2265
2266    /// The storage core this owner references (spec §10.1, S1A-001).
2267    pub fn core(&self) -> Arc<DatabaseCore> {
2268        Arc::clone(&self.core)
2269    }
2270
2271    /// The identity bound to this owner (spec §10.1, S1A-001): a catalog user
2272    /// for credentialed opens, `Credentialless` otherwise.
2273    pub fn identity(&self) -> crate::handle::HandleIdentity {
2274        match self.principal.read().as_ref() {
2275            Some(principal) => crate::handle::HandleIdentity::CatalogUser {
2276                username: principal.username.clone(),
2277                user_id: principal.user_id,
2278                created_version: principal.created_epoch,
2279            },
2280            None => crate::handle::HandleIdentity::Credentialless,
2281        }
2282    }
2283
2284    /// The current lifecycle state of the underlying storage core (S1A-004).
2285    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2286        self.core.lifecycle_state()
2287    }
2288
2289    /// Admit one operation against the storage core (S1A-004). The returned
2290    /// RAII guard releases the operation slot on drop; new operations are
2291    /// rejected once the core leaves [`crate::core::LifecycleState::Open`].
2292    pub fn operation_guard(&self) -> Result<crate::core::OperationGuard> {
2293        self.core.operation_guard()
2294    }
2295
2296    /// Build an owner facade over an existing shared core. Used by
2297    /// [`crate::manager::DatabaseManager`] to back [`crate::handle::DatabaseHandle`]s;
2298    /// exclusive `Database::open*` constructors build their own core instead.
2299    pub(crate) fn from_core(
2300        core: Arc<DatabaseCore>,
2301        principal: Option<crate::auth::Principal>,
2302        shared: bool,
2303    ) -> Self {
2304        let require_auth = core.catalog.read().require_auth;
2305        Self {
2306            core,
2307            principal: RwLock::new(principal.clone()),
2308            auth_state: crate::auth_state::AuthState::new(require_auth, principal),
2309            shared,
2310        }
2311    }
2312
2313    /// Rebind this facade's principal to the live service-principal definition
2314    /// (P0.1). Called by [`crate::handle::DatabaseHandle`] after each
2315    /// `resolve_live` so subsequent require/txn checks see current scopes.
2316    pub(crate) fn rebind_service_principal(
2317        &self,
2318        def: &crate::service_principal::ServicePrincipalDefinition,
2319    ) {
2320        let principal = crate::auth::Principal {
2321            user_id: 0,
2322            created_epoch: def.creation_version,
2323            username: format!("service:{}", def.token_id),
2324            is_admin: false,
2325            roles: Vec::new(),
2326            permissions: def.permissions.clone(),
2327        };
2328        *self.principal.write() = Some(principal.clone());
2329        self.auth_state.set_principal(Some(principal));
2330    }
2331
2332    /// Consume this owner and yield the shared core (keeps the core alive).
2333    pub(crate) fn into_core(self) -> Arc<DatabaseCore> {
2334        self.core
2335    }
2336
2337    /// Open an existing plaintext database as the one core backing shared
2338    /// handles (spec §10.1, S1A-002). Recovery, WAL opening, open-generation
2339    /// advancement, and table mounting all happen inside this call — exactly
2340    /// once per core.
2341    pub(crate) fn open_for_shared_core(root: impl AsRef<Path>) -> Result<Self> {
2342        Self::open_inner_with_lock_timeout(
2343            root,
2344            None,
2345            None,
2346            0,
2347            CoreResourceConfig::default(),
2348            OpenModeGate::SharedCore,
2349        )
2350    }
2351
2352    /// Explicitly close the final shared database owner.
2353    ///
2354    /// On a manager-issued facade (`shared`) this rejects: dropping one handle
2355    /// never closes storage while another handle exists, and shared cores are
2356    /// shut down explicitly via [`crate::handle::DatabaseHandle::shutdown`].
2357    pub fn shutdown(self: Arc<Self>) -> Result<()> {
2358        if self.shared {
2359            return Err(MongrelError::Conflict(
2360                "shared-core facades do not own storage; use DatabaseHandle::shutdown".into(),
2361            ));
2362        }
2363        match Arc::try_unwrap(self) {
2364            Ok(database) => {
2365                database.ensure_owner_process()?;
2366                // Drain + close the exclusively-owned core (S1A-004 steps:
2367                // reject new operations, wait for the drain, sync durable
2368                // state, release the file lock, mark Closed), then drop it.
2369                database.core.shutdown(std::time::Duration::from_secs(30))?;
2370                drop(database);
2371                Ok(())
2372            }
2373            Err(database) => Err(MongrelError::DatabaseBusy {
2374                strong_handles: Arc::strong_count(&database),
2375            }),
2376        }
2377    }
2378
2379    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
2380        if let Ok(canonical) = root.canonicalize() {
2381            let lock_dir = canonical.parent().ok_or_else(|| {
2382                std::io::Error::new(
2383                    std::io::ErrorKind::InvalidInput,
2384                    "database root must have a parent directory",
2385                )
2386            })?;
2387            return Ok((canonical.clone(), lock_dir.to_path_buf()));
2388        }
2389
2390        let absolute = if root.is_absolute() {
2391            root.to_path_buf()
2392        } else {
2393            std::env::current_dir()?.join(root)
2394        };
2395        let mut cursor = absolute.as_path();
2396        let mut suffix = Vec::new();
2397        while !cursor.exists() {
2398            let name = cursor.file_name().ok_or_else(|| {
2399                std::io::Error::new(
2400                    std::io::ErrorKind::NotFound,
2401                    format!("no existing ancestor for database root {}", root.display()),
2402                )
2403            })?;
2404            suffix.push(name.to_os_string());
2405            cursor = cursor.parent().ok_or_else(|| {
2406                std::io::Error::new(
2407                    std::io::ErrorKind::NotFound,
2408                    format!("no existing ancestor for database root {}", root.display()),
2409                )
2410            })?;
2411        }
2412        let lock_dir = cursor.canonicalize()?;
2413        let mut canonical = lock_dir.clone();
2414        for component in suffix.iter().rev() {
2415            canonical.push(component);
2416        }
2417        Ok((canonical, lock_dir))
2418    }
2419
2420    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<ExclusiveDatabaseLease> {
2421        use std::hash::{Hash, Hasher};
2422
2423        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
2424        let reservation =
2425            OpenReservation::acquire(DatabaseOpenKey::IntendedPath(canonical_path.clone()), root)?;
2426
2427        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2428        canonical_path.hash(&mut hasher);
2429        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
2430        let file = std::fs::OpenOptions::new()
2431            .create(true)
2432            .truncate(false)
2433            .write(true)
2434            .open(lock_path)?;
2435        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2436            return Err(MongrelError::DatabaseLocked {
2437                path: root.to_path_buf(),
2438                message: error.to_string(),
2439            });
2440        }
2441        Ok(reservation.into_lease(file, canonical_path))
2442    }
2443
2444    fn acquire_legacy_database_lock(
2445        lock: &mut ExclusiveDatabaseLease,
2446        root: &Path,
2447        timeout_ms: u32,
2448    ) -> Result<()> {
2449        let durable_root = lock
2450            .durable_root
2451            .as_ref()
2452            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
2453        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
2454        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2455            return Err(MongrelError::DatabaseLocked {
2456                path: root.to_path_buf(),
2457                message: error.to_string(),
2458            });
2459        }
2460        lock.legacy_file = Some(file);
2461        Ok(())
2462    }
2463
2464    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
2465        if path.exists() {
2466            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
2467        }
2468        let mut ancestor = path;
2469        while !ancestor.exists() {
2470            ancestor = ancestor.parent().ok_or_else(|| {
2471                MongrelError::NotFound(format!(
2472                    "no existing ancestor for database root {}",
2473                    path.display()
2474                ))
2475            })?;
2476        }
2477        let relative = path.strip_prefix(ancestor).map_err(|error| {
2478            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
2479        })?;
2480        crate::durable_file::DurableRoot::open(ancestor)?
2481            .create_directory_all_pinned(relative)
2482            .map_err(Into::into)
2483    }
2484
2485    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2486        let requested_root = root.as_ref();
2487        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
2488        let root = lock.canonical_path.clone();
2489        Self::reject_existing_database(&root)?;
2490        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
2491        if durable_root.canonical_path() != lock.canonical_path {
2492            return Err(MongrelError::Conflict(
2493                "database root changed while it was being created".into(),
2494            ));
2495        }
2496        lock.claim_root_identity(&durable_root)?;
2497        durable_root.create_directory_all(META_DIR)?;
2498        lock.durable_root = Some(durable_root);
2499        let io_root = lock
2500            .durable_root
2501            .as_ref()
2502            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2503            .io_path()?;
2504        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
2505        Self::reject_existing_database(&io_root)?;
2506        Ok((io_root, lock))
2507    }
2508
2509    fn begin_open(
2510        root: impl AsRef<Path>,
2511        lock_timeout_ms: u32,
2512    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2513        let root = root.as_ref();
2514        let canonical_root = root.canonicalize().map_err(|error| {
2515            if error.kind() == std::io::ErrorKind::NotFound {
2516                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
2517            } else {
2518                error.into()
2519            }
2520        })?;
2521        let durable_root = crate::durable_file::DurableRoot::open(&canonical_root)?;
2522        Self::begin_open_durable(durable_root, lock_timeout_ms)
2523    }
2524
2525    fn begin_open_durable(
2526        durable_root: crate::durable_file::DurableRoot,
2527        lock_timeout_ms: u32,
2528    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2529        let io_root = durable_root.io_path()?;
2530        let current_root = io_root.canonicalize()?;
2531        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
2532        lock.claim_root_identity(&durable_root)?;
2533        lock.durable_root = Some(Arc::new(durable_root));
2534        let io_root = lock
2535            .durable_root
2536            .as_ref()
2537            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2538            .io_path()?;
2539        if lock
2540            .durable_root
2541            .as_ref()
2542            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2543            .open_directory(META_DIR)
2544            .is_err()
2545        {
2546            return Err(MongrelError::NotFound(format!(
2547                "no database metadata found at {:?}",
2548                current_root
2549            )));
2550        }
2551        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
2552        Ok((io_root, lock))
2553    }
2554
2555    /// Create a fresh plaintext database at `root`.
2556    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
2557        let (root, lock) = Self::begin_create(root)?;
2558        Self::create_inner(
2559            root,
2560            None,
2561            lock,
2562            crate::storage_mode::StorageMode::Standalone,
2563        )
2564    }
2565
2566    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
2567    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
2568    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2569        let (root, lock) = Self::begin_create(root)?;
2570        let salt = crate::encryption::random_salt()?;
2571        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2572        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2573        Self::create_inner(
2574            root,
2575            Some(kek),
2576            lock,
2577            crate::storage_mode::StorageMode::Standalone,
2578        )
2579    }
2580
2581    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
2582    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
2583    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2584        let (root, lock) = Self::begin_create(root)?;
2585        let salt = crate::encryption::random_salt()?;
2586        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2587        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2588        Self::create_inner(
2589            root,
2590            Some(kek),
2591            lock,
2592            crate::storage_mode::StorageMode::Standalone,
2593        )
2594    }
2595
2596    /// Create a fresh encrypted database whose random root key is wrapped by
2597    /// an external KMS provider.
2598    pub fn create_with_kms(
2599        root: impl AsRef<Path>,
2600        provider: &dyn crate::security_hardening::KeyManagementProvider,
2601        kms_key_id: &str,
2602    ) -> Result<Self> {
2603        let (root, lock) = Self::begin_create(root)?;
2604        let kek = create_kms_kek(&root, provider, kms_key_id)?;
2605        Self::create_inner(
2606            root,
2607            Some(kek),
2608            lock,
2609            crate::storage_mode::StorageMode::Standalone,
2610        )
2611    }
2612
2613    /// Create a fresh plaintext database owned by the cluster node runtime
2614    /// (spec section 5.3): the durable marker records
2615    /// [`crate::storage_mode::StorageMode::ClusterReplica`], so normal
2616    /// `Database::open*` paths reject the directory and only
2617    /// [`Database::open_cluster_replica`] (or the read-only offline validator)
2618    /// can open it afterwards. The returned core rejects user writes: every
2619    /// mutation arrives through the replicated apply path (Stage 2E).
2620    pub fn create_cluster_replica(
2621        root: impl AsRef<Path>,
2622        cluster_id: mongreldb_types::ids::ClusterId,
2623        node_id: mongreldb_types::ids::NodeId,
2624        database_id: mongreldb_types::ids::DatabaseId,
2625    ) -> Result<Self> {
2626        let (root, lock) = Self::begin_create(root)?;
2627        Self::create_inner(
2628            root,
2629            None,
2630            lock,
2631            crate::storage_mode::StorageMode::ClusterReplica {
2632                cluster_id,
2633                node_id,
2634                database_id,
2635            },
2636        )
2637    }
2638
2639    /// Open a cluster-replica database as the cluster node runtime (spec
2640    /// section 5.3). Fails closed unless the durable marker exists and exactly
2641    /// matches `expected` (a `ClusterReplica` identity): opening a replica of
2642    /// the wrong cluster or database would corrupt both. The opened core
2643    /// rejects user writes with [`MongrelError::ReadOnlyReplica`]; mutations
2644    /// arrive through the replicated apply path (Stage 2E).
2645    pub fn open_cluster_replica(
2646        root: impl AsRef<Path>,
2647        expected: &crate::storage_mode::StorageMode,
2648    ) -> Result<Self> {
2649        let Some((cluster_id, node_id, database_id)) = expected.cluster_identity() else {
2650            return Err(MongrelError::InvalidArgument(format!(
2651                "open_cluster_replica requires a ClusterReplica identity, got {expected:?}"
2652            )));
2653        };
2654        let (root, lock) = Self::begin_open(root, 0)?;
2655        Self::open_inner_locked(
2656            root,
2657            None,
2658            lock,
2659            CoreResourceConfig::default(),
2660            OpenModeGate::ClusterRuntime {
2661                cluster_id,
2662                node_id,
2663                database_id,
2664            },
2665        )
2666    }
2667
2668    fn create_inner(
2669        root: PathBuf,
2670        kek: Option<Arc<crate::encryption::Kek>>,
2671        lock: ExclusiveDatabaseLease,
2672        storage_mode: crate::storage_mode::StorageMode,
2673    ) -> Result<Self> {
2674        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2675        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2676        let cat = Catalog::empty();
2677        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2678        Self::finish_open(
2679            root,
2680            cat,
2681            kek,
2682            meta_dek,
2683            false,
2684            None,
2685            None,
2686            None,
2687            lock,
2688            CoreResourceConfig::default(),
2689            OpenModeGate::Create(storage_mode),
2690        )
2691    }
2692
2693    /// Open an existing plaintext database.
2694    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
2695        Self::open_inner(root, None, None)
2696    }
2697
2698    /// Open an existing encrypted database with a passphrase.
2699    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2700        let (root, lock) = Self::begin_open(root, 0)?;
2701        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2702            MongrelError::Other("database root descriptor was not pinned".into())
2703        })?)?;
2704        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2705        Self::open_inner_locked(
2706            root,
2707            Some(kek),
2708            lock,
2709            CoreResourceConfig::default(),
2710            OpenModeGate::Normal,
2711        )
2712    }
2713
2714    /// Open an existing encrypted database with a configurable cross-process
2715    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
2716    pub fn open_encrypted_with_options(
2717        root: impl AsRef<Path>,
2718        passphrase: &str,
2719        options: OpenOptions,
2720    ) -> Result<Self> {
2721        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2722        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2723            MongrelError::Other("database root descriptor was not pinned".into())
2724        })?)?;
2725        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2726        let gate = if options.offline_validation {
2727            OpenModeGate::OfflineValidation
2728        } else {
2729            OpenModeGate::Normal
2730        };
2731        Self::open_inner_locked(
2732            root,
2733            Some(kek),
2734            lock,
2735            CoreResourceConfig::from_options(&options)?,
2736            gate,
2737        )
2738    }
2739
2740    /// Open an existing encrypted database using a raw high-entropy key.
2741    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2742        let (root, lock) = Self::begin_open(root, 0)?;
2743        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2744            MongrelError::Other("database root descriptor was not pinned".into())
2745        })?)?;
2746        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2747        Self::open_inner_locked(
2748            root,
2749            Some(kek),
2750            lock,
2751            CoreResourceConfig::default(),
2752            OpenModeGate::Normal,
2753        )
2754    }
2755
2756    /// Open a KMS-encrypted database. Provider identity must match the durable
2757    /// envelope and unwrapping fails closed.
2758    pub fn open_with_kms(
2759        root: impl AsRef<Path>,
2760        provider: &dyn crate::security_hardening::KeyManagementProvider,
2761    ) -> Result<Self> {
2762        Self::open_with_kms_and_options(root, provider, OpenOptions::default())
2763    }
2764
2765    /// Open a KMS-encrypted database with non-default open options.
2766    pub fn open_with_kms_and_options(
2767        root: impl AsRef<Path>,
2768        provider: &dyn crate::security_hardening::KeyManagementProvider,
2769        options: OpenOptions,
2770    ) -> Result<Self> {
2771        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2772        let kek = open_kms_kek(
2773            lock.durable_root.as_deref().ok_or_else(|| {
2774                MongrelError::Other("database root descriptor was not pinned".into())
2775            })?,
2776            provider,
2777        )?;
2778        let gate = if options.offline_validation {
2779            OpenModeGate::OfflineValidation
2780        } else {
2781            OpenModeGate::Normal
2782        };
2783        Self::open_inner_locked(
2784            root,
2785            Some(kek),
2786            lock,
2787            CoreResourceConfig::from_options(&options)?,
2788            gate,
2789        )
2790    }
2791
2792    /// Rewrap the database root key under another KMS key without stopping
2793    /// reads or writes. Data files stay encrypted under the same random root
2794    /// key; only its external envelope changes.
2795    pub fn rotate_kms_key(
2796        &self,
2797        provider: &dyn crate::security_hardening::KeyManagementProvider,
2798        new_kms_key_id: &str,
2799    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2800        let _rotation_guard = self.core.kms_rotation_lock.lock();
2801        if new_kms_key_id.is_empty() {
2802            return Err(MongrelError::InvalidArgument(
2803                "KMS key id must not be empty".into(),
2804            ));
2805        }
2806        let durable_root = Arc::clone(&self.core.durable_root);
2807        let root = durable_root.io_path()?;
2808        let envelope = read_kms_key_envelope(&durable_root)?;
2809        if envelope.provider_id != provider.provider_id() {
2810            return Err(MongrelError::Encryption(format!(
2811                "KMS provider mismatch: database requires {:?}, got {:?}",
2812                envelope.provider_id,
2813                provider.provider_id()
2814            )));
2815        }
2816        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2817        if let Some(record) = journal
2818            .load()
2819            .map_err(|error| MongrelError::Encryption(error.to_string()))?
2820            .filter(|record| record.phase != crate::security_hardening::KeyRotationPhase::Succeeded)
2821        {
2822            if record.to_key_id != new_kms_key_id {
2823                return Err(MongrelError::Conflict(format!(
2824                    "KMS rotation to {:?} is already in progress",
2825                    record.to_key_id
2826                )));
2827            }
2828            return self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record);
2829        }
2830        let now_micros = std::time::SystemTime::now()
2831            .duration_since(std::time::UNIX_EPOCH)
2832            .unwrap_or_default()
2833            .as_micros() as u64;
2834        let mut record = crate::security_hardening::KeyRotationRecord::new(
2835            envelope.wrapped_key.key_version.clone(),
2836            String::new(),
2837            now_micros,
2838        );
2839        record.from_key_id = envelope.wrapped_key.kms_key_id.clone();
2840        record.to_key_id = new_kms_key_id.to_owned();
2841        persist_key_rotation(&journal, &record)?;
2842        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
2843    }
2844
2845    /// Retry a rotation that durably entered `Failed`.
2846    pub fn retry_kms_key_rotation(
2847        &self,
2848        provider: &dyn crate::security_hardening::KeyManagementProvider,
2849    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2850        let _rotation_guard = self.core.kms_rotation_lock.lock();
2851        let durable_root = Arc::clone(&self.core.durable_root);
2852        let root = durable_root.io_path()?;
2853        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2854        let mut record = journal
2855            .load()
2856            .map_err(|error| MongrelError::Encryption(error.to_string()))?
2857            .ok_or_else(|| MongrelError::NotFound("KMS rotation journal".into()))?;
2858        if record.phase != crate::security_hardening::KeyRotationPhase::Failed {
2859            return Err(MongrelError::Conflict(
2860                "KMS rotation is not in Failed state".into(),
2861            ));
2862        }
2863        record.retry();
2864        persist_key_rotation(&journal, &record)?;
2865        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
2866    }
2867
2868    fn run_kms_key_rotation(
2869        &self,
2870        provider: &dyn crate::security_hardening::KeyManagementProvider,
2871        durable_root: &crate::durable_file::DurableRoot,
2872        root: &Path,
2873        journal: &crate::security_hardening::KeyRotationJournal,
2874        mut record: crate::security_hardening::KeyRotationRecord,
2875    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2876        use crate::security_hardening::{KeyRotationPhase, KmsDatabaseKeyEnvelope};
2877        loop {
2878            match record.phase {
2879                KeyRotationPhase::Pending => advance_key_rotation(journal, &mut record)?,
2880                KeyRotationPhase::WrappingNewKey => {
2881                    if record.staged_wrapped_key.is_none() {
2882                        let current = read_kms_key_envelope(durable_root)?;
2883                        let wrapped =
2884                            match provider.rewrap_key(&current.wrapped_key, &record.to_key_id) {
2885                                Ok(wrapped) => wrapped,
2886                                Err(error) => {
2887                                    return fail_key_rotation(journal, &mut record, error);
2888                                }
2889                            };
2890                        record.to_version = wrapped.key_version.clone();
2891                        record.staged_wrapped_key = Some(wrapped);
2892                        persist_key_rotation(journal, &record)?;
2893                    }
2894                    advance_key_rotation(journal, &mut record)?;
2895                }
2896                KeyRotationPhase::DualRead => {
2897                    let old_envelope = read_kms_key_envelope(durable_root)?;
2898                    let new_envelope = KmsDatabaseKeyEnvelope {
2899                        provider_id: provider.provider_id().to_owned(),
2900                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
2901                            MongrelError::Encryption(
2902                                "KMS rotation has no staged wrapped key".into(),
2903                            )
2904                        })?,
2905                    };
2906                    let old_raw = match unwrap_kms_database_key(provider, &old_envelope) {
2907                        Ok(key) => key,
2908                        Err(error) => {
2909                            return fail_key_rotation(journal, &mut record, error);
2910                        }
2911                    };
2912                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
2913                        Ok(key) => key,
2914                        Err(error) => {
2915                            return fail_key_rotation(journal, &mut record, error);
2916                        }
2917                    };
2918                    if !bool::from(old_raw.as_slice().ct_eq(new_raw.as_slice())) {
2919                        return fail_key_rotation(
2920                            journal,
2921                            &mut record,
2922                            "KMS rewrap changed the database root key",
2923                        );
2924                    }
2925                    advance_key_rotation(journal, &mut record)?;
2926                }
2927                KeyRotationPhase::Reencrypting => {
2928                    // Envelope rotation keeps the database root key stable, so
2929                    // pages, WAL records, and active readers need no rewrite.
2930                    advance_key_rotation(journal, &mut record)?;
2931                }
2932                KeyRotationPhase::Validating => {
2933                    let new_envelope = KmsDatabaseKeyEnvelope {
2934                        provider_id: provider.provider_id().to_owned(),
2935                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
2936                            MongrelError::Encryption(
2937                                "KMS rotation has no staged wrapped key".into(),
2938                            )
2939                        })?,
2940                    };
2941                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
2942                        Ok(key) => key,
2943                        Err(error) => {
2944                            return fail_key_rotation(journal, &mut record, error);
2945                        }
2946                    };
2947                    let salt = read_encryption_salt(durable_root)?;
2948                    let candidate = crate::encryption::Kek::from_raw_key(new_raw.as_ref(), &salt)?;
2949                    let candidate_meta = candidate.derive_meta_key();
2950                    let Some(active_meta) = self.core.meta_dek.as_ref() else {
2951                        return fail_key_rotation(
2952                            journal,
2953                            &mut record,
2954                            "KMS metadata exists on a plaintext database",
2955                        );
2956                    };
2957                    if !bool::from(candidate_meta.as_ref().ct_eq(active_meta.as_slice())) {
2958                        return fail_key_rotation(
2959                            journal,
2960                            &mut record,
2961                            "KMS rewrap validation failed",
2962                        );
2963                    }
2964                    advance_key_rotation(journal, &mut record)?;
2965                }
2966                KeyRotationPhase::Published => {
2967                    let envelope = KmsDatabaseKeyEnvelope {
2968                        provider_id: provider.provider_id().to_owned(),
2969                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
2970                            MongrelError::Encryption(
2971                                "KMS rotation has no staged wrapped key".into(),
2972                            )
2973                        })?,
2974                    };
2975                    write_kms_key_envelope(root, &envelope)?;
2976                    advance_key_rotation(journal, &mut record)?;
2977                }
2978                KeyRotationPhase::RetiringOldKey => {
2979                    advance_key_rotation(journal, &mut record)?;
2980                }
2981                KeyRotationPhase::Succeeded => return Ok(record),
2982                KeyRotationPhase::Failed => {
2983                    return Err(MongrelError::Encryption(
2984                        "failed KMS rotation must be explicitly retried".into(),
2985                    ));
2986                }
2987            }
2988        }
2989    }
2990
2991    /// Open an existing plaintext database that has `require_auth = true`,
2992    /// verifying the supplied credentials up front and caching the resolved
2993    /// [`Principal`] on the returned handle. Every subsequent operation will
2994    /// be checked against that principal.
2995    ///
2996    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
2997    /// `require_auth` enabled — callers must pick the matching constructor for
2998    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
2999    /// bad username/password.
3000    ///
3001    /// See `docs/15-credential-enforcement.md`.
3002    pub fn open_with_credentials(
3003        root: impl AsRef<Path>,
3004        username: &str,
3005        password: &str,
3006    ) -> Result<Self> {
3007        Self::open_inner_with_credentials(root, None, username, password)
3008    }
3009
3010    /// Open with credentials and a configurable cross-process lock timeout.
3011    /// Mirrors [`open_with_options`](Self::open_with_options) for the
3012    /// credentialed path.
3013    pub fn open_with_credentials_and_options(
3014        root: impl AsRef<Path>,
3015        username: &str,
3016        password: &str,
3017        options: OpenOptions,
3018    ) -> Result<Self> {
3019        let gate = if options.offline_validation {
3020            OpenModeGate::OfflineValidation
3021        } else {
3022            OpenModeGate::Normal
3023        };
3024        Self::open_inner_with_credentials_and_lock_timeout(
3025            root,
3026            None,
3027            username,
3028            password,
3029            options.lock_timeout_ms,
3030            CoreResourceConfig::from_options(&options)?,
3031            gate,
3032        )
3033    }
3034
3035    /// Open an existing encrypted database that has `require_auth = true`,
3036    /// combining the encryption passphrase flow with credential verification.
3037    pub fn open_encrypted_with_credentials(
3038        root: impl AsRef<Path>,
3039        passphrase: &str,
3040        username: &str,
3041        password: &str,
3042    ) -> Result<Self> {
3043        let (root, lock) = Self::begin_open(root, 0)?;
3044        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3045            MongrelError::Other("database root descriptor was not pinned".into())
3046        })?)?;
3047        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3048        Self::open_inner_with_credentials_locked(
3049            root,
3050            Some(kek),
3051            username,
3052            password,
3053            lock,
3054            CoreResourceConfig::default(),
3055            OpenModeGate::Normal,
3056        )
3057    }
3058
3059    /// Open an encrypted + credentialed database with a configurable
3060    /// cross-process lock timeout. Mirrors
3061    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
3062    pub fn open_encrypted_with_credentials_and_options(
3063        root: impl AsRef<Path>,
3064        passphrase: &str,
3065        username: &str,
3066        password: &str,
3067        options: OpenOptions,
3068    ) -> Result<Self> {
3069        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
3070        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3071            MongrelError::Other("database root descriptor was not pinned".into())
3072        })?)?;
3073        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3074        let gate = if options.offline_validation {
3075            OpenModeGate::OfflineValidation
3076        } else {
3077            OpenModeGate::Normal
3078        };
3079        Self::open_inner_with_credentials_locked(
3080            root,
3081            Some(kek),
3082            username,
3083            password,
3084            lock,
3085            CoreResourceConfig::from_options(&options)?,
3086            gate,
3087        )
3088    }
3089
3090    /// Open a KMS-encrypted database and authenticate the returned handle.
3091    pub fn open_with_kms_and_credentials(
3092        root: impl AsRef<Path>,
3093        provider: &dyn crate::security_hardening::KeyManagementProvider,
3094        username: &str,
3095        password: &str,
3096    ) -> Result<Self> {
3097        let (root, lock) = Self::begin_open(root, 0)?;
3098        let kek = open_kms_kek(
3099            lock.durable_root.as_deref().ok_or_else(|| {
3100                MongrelError::Other("database root descriptor was not pinned".into())
3101            })?,
3102            provider,
3103        )?;
3104        Self::open_inner_with_credentials_locked(
3105            root,
3106            Some(kek),
3107            username,
3108            password,
3109            lock,
3110            CoreResourceConfig::default(),
3111            OpenModeGate::Normal,
3112        )
3113    }
3114
3115    /// Open an existing database with non-default [`OpenOptions`].
3116    ///
3117    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
3118    /// rather than the fail-fast default. The other open constructors keep
3119    /// their previous defaults; use their `*_with_options` variants when they
3120    /// need the same timeout behavior.
3121    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
3122        // No encryption, no auth; encrypted and credentialed paths have their
3123        // own `*_with_options` constructors.
3124        let gate = if options.offline_validation {
3125            OpenModeGate::OfflineValidation
3126        } else {
3127            OpenModeGate::Normal
3128        };
3129        Self::open_inner_with_lock_timeout(
3130            root,
3131            None,
3132            None,
3133            options.lock_timeout_ms,
3134            CoreResourceConfig::from_options(&options)?,
3135            gate,
3136        )
3137    }
3138
3139    fn open_inner_with_lock_timeout(
3140        root: impl AsRef<Path>,
3141        kek: Option<Arc<crate::encryption::Kek>>,
3142        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3143        lock_timeout_ms: u32,
3144        resources: CoreResourceConfig,
3145        mode_gate: OpenModeGate,
3146    ) -> Result<Self> {
3147        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3148        Self::open_inner_locked(root, kek, lock, resources, mode_gate)
3149    }
3150
3151    fn open_inner_locked(
3152        root: PathBuf,
3153        kek: Option<Arc<crate::encryption::Kek>>,
3154        lock: ExclusiveDatabaseLease,
3155        resources: CoreResourceConfig,
3156        mode_gate: OpenModeGate,
3157    ) -> Result<Self> {
3158        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3159        let mut cat = catalog::read_durable(
3160            lock.durable_root.as_deref().ok_or_else(|| {
3161                MongrelError::Other("database root descriptor was not pinned".into())
3162            })?,
3163            meta_dek.as_ref(),
3164        )?
3165        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3166        let recovery_checkpoint = cat.clone();
3167
3168        // CATALOG is only a checkpoint. Authentication must use the
3169        // authoritative catalog after committed WAL DDL/security replay.
3170        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3171        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3172            lock.durable_root.as_deref().ok_or_else(|| {
3173                MongrelError::Other("database root descriptor was not pinned".into())
3174            })?,
3175            wal_dek.as_ref(),
3176        )?;
3177        recover_ddl_from_records(
3178            &root,
3179            Some(lock.durable_root.as_deref().ok_or_else(|| {
3180                MongrelError::Other("database root descriptor was not pinned".into())
3181            })?),
3182            &mut cat,
3183            meta_dek.as_ref(),
3184            false,
3185            None,
3186            &recovery_records,
3187        )?;
3188        Self::finish_open(
3189            root,
3190            cat,
3191            kek,
3192            meta_dek,
3193            true,
3194            Some(recovery_checkpoint),
3195            Some(recovery_records),
3196            None,
3197            lock,
3198            resources,
3199            mode_gate,
3200        )
3201    }
3202
3203    /// Shared credentialed-open inner: read the catalog, verify the database
3204    /// requires auth, verify the password, resolve the principal, and pass
3205    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
3206    /// problem where `finish_open`'s fail-closed check (`require_auth &&
3207    /// principal.is_none()`) would fire before a post-open `authenticate()`
3208    /// could supply the principal.
3209    fn open_inner_with_credentials(
3210        root: impl AsRef<Path>,
3211        kek: Option<Arc<crate::encryption::Kek>>,
3212        username: &str,
3213        password: &str,
3214    ) -> Result<Self> {
3215        Self::open_inner_with_credentials_and_lock_timeout(
3216            root,
3217            kek,
3218            username,
3219            password,
3220            0,
3221            CoreResourceConfig::default(),
3222            OpenModeGate::Normal,
3223        )
3224    }
3225
3226    /// Credentialed-open with an explicit cross-process lock timeout. The
3227    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
3228    /// historical fail-fast behavior via the wrapper above.
3229    #[allow(clippy::too_many_arguments)]
3230    fn open_inner_with_credentials_and_lock_timeout(
3231        root: impl AsRef<Path>,
3232        kek: Option<Arc<crate::encryption::Kek>>,
3233        username: &str,
3234        password: &str,
3235        lock_timeout_ms: u32,
3236        resources: CoreResourceConfig,
3237        mode_gate: OpenModeGate,
3238    ) -> Result<Self> {
3239        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3240        Self::open_inner_with_credentials_locked(
3241            root, kek, username, password, lock, resources, mode_gate,
3242        )
3243    }
3244
3245    #[allow(clippy::too_many_arguments)]
3246    fn open_inner_with_credentials_locked(
3247        root: PathBuf,
3248        kek: Option<Arc<crate::encryption::Kek>>,
3249        username: &str,
3250        password: &str,
3251        lock: ExclusiveDatabaseLease,
3252        resources: CoreResourceConfig,
3253        mode_gate: OpenModeGate,
3254    ) -> Result<Self> {
3255        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3256        let mut cat = catalog::read_durable(
3257            lock.durable_root.as_deref().ok_or_else(|| {
3258                MongrelError::Other("database root descriptor was not pinned".into())
3259            })?,
3260            meta_dek.as_ref(),
3261        )?
3262        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3263        let recovery_checkpoint = cat.clone();
3264
3265        // Never verify against a stale checkpoint. A committed password,
3266        // user, role, or auth-mode change in WAL is authoritative.
3267        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3268        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3269            lock.durable_root.as_deref().ok_or_else(|| {
3270                MongrelError::Other("database root descriptor was not pinned".into())
3271            })?,
3272            wal_dek.as_ref(),
3273        )?;
3274        recover_ddl_from_records(
3275            &root,
3276            Some(lock.durable_root.as_deref().ok_or_else(|| {
3277                MongrelError::Other("database root descriptor was not pinned".into())
3278            })?),
3279            &mut cat,
3280            meta_dek.as_ref(),
3281            false,
3282            None,
3283            &recovery_records,
3284        )?;
3285
3286        // Fail early if the database is not in require_auth mode — the caller
3287        // picked the wrong constructor.
3288        if !cat.require_auth {
3289            return Err(MongrelError::AuthNotRequired);
3290        }
3291
3292        // Verify credentials against the on-disk catalog before constructing
3293        // the full Database handle. This reads users/hashes directly from the
3294        // loaded catalog rather than going through the Database::verify_user
3295        // method (which requires a constructed Database).
3296        let user = cat
3297            .users
3298            .iter()
3299            .find(|u| u.username == username)
3300            .filter(|u| !u.password_hash.is_empty())
3301            .ok_or_else(|| MongrelError::InvalidCredentials {
3302                username: username.to_string(),
3303            })?;
3304        let password_ok = crate::auth::verify_password(password, &user.password_hash)
3305            .map_err(MongrelError::Other)?;
3306        if !password_ok {
3307            return Err(MongrelError::InvalidCredentials {
3308                username: username.to_string(),
3309            });
3310        }
3311
3312        // Resolve the principal from the catalog (roles + permissions).
3313        let principal =
3314            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
3315                MongrelError::InvalidCredentials {
3316                    username: username.to_string(),
3317                }
3318            })?;
3319
3320        Self::finish_open(
3321            root,
3322            cat,
3323            kek,
3324            meta_dek,
3325            true,
3326            Some(recovery_checkpoint),
3327            Some(recovery_records),
3328            Some(principal),
3329            lock,
3330            resources,
3331            mode_gate,
3332        )
3333    }
3334
3335    /// Create a fresh plaintext database with `require_auth = true` and a
3336    /// single admin user. The returned handle is already authenticated as
3337    /// that admin — every subsequent operation is checked against the admin
3338    /// principal (which bypasses all permission checks via `is_admin`).
3339    ///
3340    /// This is the bootstrap path: there is no window where the database
3341    /// requires auth but has no users.
3342    ///
3343    /// See `docs/15-credential-enforcement.md`.
3344    pub fn create_with_credentials(
3345        root: impl AsRef<Path>,
3346        admin_username: &str,
3347        admin_password: &str,
3348    ) -> Result<Self> {
3349        let (root, lock) = Self::begin_create(root)?;
3350        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
3351    }
3352
3353    /// Create a fresh encrypted database with `require_auth = true` and a
3354    /// single admin user. Composes encryption-at-rest with credential
3355    /// enforcement.
3356    pub fn create_encrypted_with_credentials(
3357        root: impl AsRef<Path>,
3358        passphrase: &str,
3359        admin_username: &str,
3360        admin_password: &str,
3361    ) -> Result<Self> {
3362        let (root, lock) = Self::begin_create(root)?;
3363        let salt = crate::encryption::random_salt()?;
3364        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
3365        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3366        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3367    }
3368
3369    /// Create a KMS-encrypted database with an authenticated admin handle.
3370    pub fn create_with_kms_and_credentials(
3371        root: impl AsRef<Path>,
3372        provider: &dyn crate::security_hardening::KeyManagementProvider,
3373        kms_key_id: &str,
3374        admin_username: &str,
3375        admin_password: &str,
3376    ) -> Result<Self> {
3377        let (root, lock) = Self::begin_create(root)?;
3378        let kek = create_kms_kek(&root, provider, kms_key_id)?;
3379        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3380    }
3381
3382    fn create_inner_with_credentials(
3383        root: PathBuf,
3384        kek: Option<Arc<crate::encryption::Kek>>,
3385        admin_username: &str,
3386        admin_password: &str,
3387        lock: ExclusiveDatabaseLease,
3388    ) -> Result<Self> {
3389        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
3390        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3391
3392        // Build the initial catalog with require_auth = true and one admin user.
3393        let password_hash =
3394            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
3395        let scram_sha_256 =
3396            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
3397        let mysql_caching_sha2 =
3398            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
3399        let mut cat = Catalog::empty();
3400        cat.require_auth = true;
3401        cat.next_user_id = 2;
3402        cat.users.push(crate::auth::UserEntry {
3403            id: 1,
3404            username: admin_username.to_string(),
3405            password_hash,
3406            scram_sha_256: Some(scram_sha_256),
3407            mysql_caching_sha2: Some(mysql_caching_sha2),
3408            roles: Vec::new(),
3409            is_admin: true,
3410            created_epoch: 0,
3411        });
3412        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3413
3414        // The handle is constructed already authenticated as the admin user
3415        // it just created — no separate verify step needed.
3416        let admin_principal = crate::auth::Principal {
3417            user_id: 1,
3418            created_epoch: 0,
3419            username: admin_username.to_string(),
3420            is_admin: true,
3421            roles: Vec::new(),
3422            permissions: Vec::new(),
3423        };
3424        Self::finish_open(
3425            root,
3426            cat,
3427            kek,
3428            meta_dek,
3429            false,
3430            None,
3431            None,
3432            Some(admin_principal),
3433            lock,
3434            CoreResourceConfig::default(),
3435            OpenModeGate::Create(crate::storage_mode::StorageMode::Standalone),
3436        )
3437    }
3438
3439    fn reject_existing_database(root: &Path) -> Result<()> {
3440        // Refuse to overwrite an existing database. If CATALOG exists, the
3441        // directory already contains a real database; replacing it destroys data.
3442        if root.join(catalog::CATALOG_FILENAME).exists() {
3443            return Err(MongrelError::InvalidArgument(format!(
3444                "database already exists at {}; use Database::open() to open it, \
3445                 or remove the directory first",
3446                root.display()
3447            )));
3448        }
3449        Ok(())
3450    }
3451
3452    fn open_inner(
3453        root: impl AsRef<Path>,
3454        kek: Option<Arc<crate::encryption::Kek>>,
3455        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3456    ) -> Result<Self> {
3457        Self::open_inner_with_lock_timeout(
3458            root,
3459            kek,
3460            None,
3461            0,
3462            CoreResourceConfig::default(),
3463            OpenModeGate::Normal,
3464        )
3465    }
3466
3467    /// Open an existing plaintext database through the special
3468    /// offline-validation API (spec section 5.3): any storage mode, read-only.
3469    pub(crate) fn open_offline_validation(root: impl AsRef<Path>) -> Result<Self> {
3470        Self::open_inner_with_lock_timeout(
3471            root,
3472            None,
3473            None,
3474            0,
3475            CoreResourceConfig::default(),
3476            OpenModeGate::OfflineValidation,
3477        )
3478    }
3479
3480    /// Internal recovery open for a staging directory explicitly marked as a
3481    /// read-only replica. It bypasses user authentication only so PITR can
3482    /// replay auth-mode and password transitions; it is not public API.
3483    pub(crate) fn open_replica_recovery_durable(
3484        root: &crate::durable_file::DurableRoot,
3485    ) -> Result<Self> {
3486        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3487        Self::open_replica_recovery_inner(root, None, lock)
3488    }
3489
3490    pub(crate) fn open_encrypted_replica_recovery_durable(
3491        root: &crate::durable_file::DurableRoot,
3492        passphrase: &str,
3493    ) -> Result<Self> {
3494        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3495        let salt = read_encryption_salt(root)?;
3496        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3497        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
3498    }
3499
3500    fn open_replica_recovery_inner(
3501        root: PathBuf,
3502        kek: Option<Arc<crate::encryption::Kek>>,
3503        lock: ExclusiveDatabaseLease,
3504    ) -> Result<Self> {
3505        if !root.join(META_DIR).join("replica").is_file() {
3506            return Err(MongrelError::InvalidArgument(
3507                "recovery auth bypass requires a marked replica staging directory".into(),
3508            ));
3509        }
3510        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3511        let mut cat = catalog::read_durable(
3512            lock.durable_root.as_deref().ok_or_else(|| {
3513                MongrelError::Other("database root descriptor was not pinned".into())
3514            })?,
3515            meta_dek.as_ref(),
3516        )?
3517        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3518        let recovery_checkpoint = cat.clone();
3519        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3520        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
3521            lock.durable_root.as_deref().ok_or_else(|| {
3522                MongrelError::Other("database root descriptor was not pinned".into())
3523            })?,
3524            wal_dek.as_ref(),
3525        )?;
3526        recover_ddl_from_records(
3527            &root,
3528            Some(lock.durable_root.as_deref().ok_or_else(|| {
3529                MongrelError::Other("database root descriptor was not pinned".into())
3530            })?),
3531            &mut cat,
3532            meta_dek.as_ref(),
3533            false,
3534            None,
3535            &recovery_records,
3536        )?;
3537        let principal = if cat.require_auth {
3538            cat.users
3539                .iter()
3540                .find(|user| user.is_admin)
3541                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
3542                .ok_or_else(|| {
3543                    MongrelError::Schema(
3544                        "authenticated replica catalog has no recoverable admin".into(),
3545                    )
3546                })?
3547                .into()
3548        } else {
3549            None
3550        };
3551        Self::finish_open(
3552            root,
3553            cat,
3554            kek,
3555            meta_dek,
3556            true,
3557            Some(recovery_checkpoint),
3558            Some(recovery_records),
3559            principal,
3560            lock,
3561            CoreResourceConfig::default(),
3562            OpenModeGate::Normal,
3563        )
3564    }
3565
3566    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
3567    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
3568    ///
3569    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
3570    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
3571    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
3572    ///
3573    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
3574    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
3575    /// caller never blocks past its budget even at the tail of a busy lock
3576    /// holder's lock-window.
3577    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
3578        use fs2::FileExt;
3579        if timeout_ms == 0 {
3580            return f.try_lock_exclusive();
3581        }
3582        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
3583        let deadline =
3584            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
3585        let mut next_sleep = std::time::Duration::from_millis(1);
3586        let mut recorded_wait = false;
3587        loop {
3588            match f.try_lock_exclusive() {
3589                Ok(()) => return Ok(()),
3590                Err(e) if Self::is_file_lock_contended(&e) => {
3591                    if !recorded_wait {
3592                        DATABASE_OPEN_WAIT_COUNT.fetch_add(1, Ordering::Relaxed);
3593                        recorded_wait = true;
3594                    }
3595                    let now = std::time::Instant::now();
3596                    if now >= deadline {
3597                        return Err(std::io::Error::new(
3598                            std::io::ErrorKind::WouldBlock,
3599                            format!("could not acquire database lock within {timeout_ms}ms"),
3600                        ));
3601                    }
3602                    let remaining = deadline - now;
3603                    let sleep = next_sleep.min(remaining);
3604                    std::thread::sleep(sleep);
3605                    // Cap the per-iteration sleep so a single back-off step
3606                    // never overshoots the remaining budget.
3607                    next_sleep = next_sleep
3608                        .saturating_mul(10)
3609                        .min(std::time::Duration::from_millis(50));
3610                }
3611                Err(e) => return Err(e),
3612            }
3613        }
3614    }
3615
3616    fn is_file_lock_contended(error: &std::io::Error) -> bool {
3617        if error.kind() == std::io::ErrorKind::WouldBlock {
3618            return true;
3619        }
3620        #[cfg(windows)]
3621        {
3622            // LockFileEx reports ERROR_LOCK_VIOLATION without mapping it to
3623            // ErrorKind::WouldBlock.
3624            return error.raw_os_error() == Some(33);
3625        }
3626        #[cfg(not(windows))]
3627        false
3628    }
3629
3630    #[allow(clippy::too_many_arguments)]
3631    fn finish_open(
3632        root: PathBuf,
3633        cat: Catalog,
3634        kek: Option<Arc<crate::encryption::Kek>>,
3635        meta_dek: Option<[u8; META_DEK_LEN]>,
3636        existing: bool,
3637        recovery_checkpoint: Option<Catalog>,
3638        recovery_records: Option<Vec<crate::wal::Record>>,
3639        principal: Option<crate::auth::Principal>,
3640        lock: ExclusiveDatabaseLease,
3641        resources: CoreResourceConfig,
3642        mode_gate: OpenModeGate,
3643    ) -> Result<Self> {
3644        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
3645            MongrelError::Other("database root descriptor was not pinned".into())
3646        })?);
3647        // Storage-mode gate (spec section 5.3, Stage 2E): read the durable
3648        // marker before any recovery side effect and fail closed on corrupt
3649        // or forbidden modes. Legacy databases have no marker and are treated
3650        // as Standalone (backfilled below, after recovery succeeds).
3651        let storage_mode = if existing {
3652            crate::storage_mode::read(&durable_root)?
3653        } else {
3654            None
3655        };
3656        match (&mode_gate, &storage_mode) {
3657            (OpenModeGate::Create(_), _) => {}
3658            (OpenModeGate::Normal | OpenModeGate::SharedCore, mode) => {
3659                crate::storage_mode::check_open(mode.as_ref(), false)?
3660            }
3661            (OpenModeGate::OfflineValidation, mode) => {
3662                crate::storage_mode::check_open(mode.as_ref(), true)?
3663            }
3664            (
3665                OpenModeGate::ClusterRuntime {
3666                    cluster_id,
3667                    node_id,
3668                    database_id,
3669                },
3670                Some(actual),
3671            ) => {
3672                let expected = crate::storage_mode::StorageMode::ClusterReplica {
3673                    cluster_id: *cluster_id,
3674                    node_id: *node_id,
3675                    database_id: *database_id,
3676                };
3677                if *actual != expected {
3678                    return Err(
3679                        crate::storage_mode::StorageModeError::IdentityMismatch(format!(
3680                            "expected {expected:?}, marker holds {actual:?}"
3681                        ))
3682                        .into(),
3683                    );
3684                }
3685            }
3686            (OpenModeGate::ClusterRuntime { .. }, None) => {
3687                return Err(crate::storage_mode::StorageModeError::IdentityMismatch(
3688                    "expected a ClusterReplica marker; directory has none".into(),
3689                )
3690                .into());
3691            }
3692        }
3693        let read_only = match &mode_gate {
3694            OpenModeGate::OfflineValidation | OpenModeGate::ClusterRuntime { .. } => true,
3695            // A freshly created cluster replica rejects user writes from the
3696            // start: mutations arrive through the replicated apply path only.
3697            OpenModeGate::Create(mode) => mode.cluster_identity().is_some(),
3698            OpenModeGate::Normal | OpenModeGate::SharedCore => false,
3699        } || if existing {
3700            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
3701                Ok(_) => true,
3702                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
3703                Err(error) => return Err(error.into()),
3704            }
3705        } else {
3706            false
3707        };
3708        let recovered_catalog = cat;
3709        let mut cat = recovered_catalog.clone();
3710        let abandoned = if existing && !read_only {
3711            let abandoned = cat
3712                .tables
3713                .iter()
3714                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
3715                .map(|entry| entry.table_id)
3716                .collect::<Vec<_>>();
3717            for entry in &mut cat.tables {
3718                if abandoned.contains(&entry.table_id) {
3719                    entry.state = TableState::Dropped {
3720                        at_epoch: cat.db_epoch,
3721                    };
3722                }
3723            }
3724            abandoned
3725        } else {
3726            Vec::new()
3727        };
3728        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3729        let recovery_records = match (existing, recovery_records) {
3730            (true, Some(records)) => records,
3731            (true, None) => {
3732                return Err(MongrelError::Other(
3733                    "existing open has no validated WAL recovery plan".into(),
3734                ))
3735            }
3736            (false, _) => Vec::new(),
3737        };
3738        let (history_epochs, history_start) =
3739            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
3740        let open_generation = if existing {
3741            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
3742                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3743            })?;
3744            let recovered_table_ids = cat
3745                .tables
3746                .iter()
3747                .filter(|entry| {
3748                    checkpoint
3749                        .tables
3750                        .iter()
3751                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
3752                })
3753                .map(|entry| entry.table_id)
3754                .collect::<HashSet<_>>();
3755            let reconciled_table_ids = cat
3756                .tables
3757                .iter()
3758                .filter(|entry| {
3759                    checkpoint
3760                        .tables
3761                        .iter()
3762                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
3763                        .is_some_and(|checkpoint| {
3764                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
3765                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
3766                        })
3767                })
3768                .map(|entry| entry.table_id)
3769                .collect::<HashSet<_>>();
3770            validate_shared_wal_recovery_plan(
3771                &durable_root,
3772                &cat,
3773                &recovered_table_ids,
3774                &reconciled_table_ids,
3775                meta_dek.as_ref(),
3776                kek.clone(),
3777                &recovery_records,
3778            )?;
3779            let retained_generation = recovery_records
3780                .iter()
3781                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
3782                .map(|record| record.txn_id >> 32)
3783                .max()
3784                .unwrap_or(0);
3785            let head_generation =
3786                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
3787            let durable_floor = match head_generation {
3788                Some(head) if retained_generation > head => {
3789                    return Err(MongrelError::CorruptWal {
3790                        offset: retained_generation,
3791                        reason: format!(
3792                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
3793                        ),
3794                    })
3795                }
3796                Some(head) => head,
3797                None => retained_generation,
3798            };
3799            let stored = catalog::read_generation(&durable_root)?;
3800            if stored.is_some_and(|generation| generation < durable_floor) {
3801                return Err(MongrelError::Other(format!(
3802                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
3803                )));
3804            }
3805            let bumped = stored
3806                .unwrap_or(durable_floor)
3807                .max(durable_floor)
3808                .checked_add(1)
3809                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
3810            if bumped > u32::MAX as u64 {
3811                return Err(MongrelError::Full(
3812                    "open-generation namespace exhausted".into(),
3813                ));
3814            }
3815            bumped
3816        } else {
3817            0
3818        };
3819        let principal = if cat.require_auth && !matches!(&mode_gate, OpenModeGate::SharedCore) {
3820            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3821            Some(
3822                Self::resolve_bound_principal_from_catalog(&cat, supplied)
3823                    .ok_or(MongrelError::AuthRequired)?,
3824            )
3825        } else {
3826            principal
3827        };
3828        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
3829        if existing {
3830            for entry in &cat.tables {
3831                if !matches!(entry.state, TableState::Live) {
3832                    continue;
3833                }
3834                match durable_root
3835                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
3836                {
3837                    Ok(root) => {
3838                        table_roots.insert(entry.table_id, Arc::new(root));
3839                    }
3840                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3841                    Err(error) => return Err(error.into()),
3842                }
3843            }
3844        }
3845
3846        // No database-tree mutation occurs above this point. DDL, row payloads,
3847        // immutable runs, auth state, retention, and generation state have all
3848        // been validated against the authoritative recovered catalog.
3849        if existing {
3850            let mut applied = recovery_checkpoint.ok_or_else(|| {
3851                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3852            })?;
3853            recover_ddl_from_records(
3854                &root,
3855                Some(&durable_root),
3856                &mut applied,
3857                meta_dek.as_ref(),
3858                true,
3859                Some(&table_roots),
3860                &recovery_records,
3861            )?;
3862            let catalog_value = |catalog: &Catalog| {
3863                serde_json::to_value(catalog)
3864                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
3865            };
3866            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
3867                return Err(MongrelError::CorruptWal {
3868                    offset: 0,
3869                    reason: "validated and applied DDL recovery plans differ".into(),
3870                });
3871            }
3872            if catalog_value(&cat)? != catalog_value(&applied)? {
3873                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3874            }
3875            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
3876            if !read_only {
3877                sweep_unreferenced_table_dirs(&root, &cat)?;
3878            }
3879            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
3880                Ok(()) => {}
3881                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3882                Err(error) => return Err(error.into()),
3883            }
3884        }
3885
3886        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
3887        let snapshots = Arc::new(SnapshotRegistry::new());
3888        snapshots.configure_history(history_epochs, history_start);
3889        // S1E-003: exactly one node-level memory governor per storage core.
3890        // Both caches reserve under it (their live bytes always show up in
3891        // the governor's accounting) and register as reclaimable subsystems
3892        // the governor drives under escalation step 2.
3893        let memory_governor = crate::memory::MemoryGovernor::new(
3894            crate::memory::GovernorConfig::new(resources.memory_budget_bytes),
3895        )
3896        .map_err(|error| MongrelError::InvalidArgument(format!("memory governor: {error}")))?;
3897        // S1E-004: exactly one spill manager per core, rooted at
3898        // `<db-root>/temp/spill`; opening it runs the startup sweep that
3899        // deletes every stale spill file a prior process run left behind.
3900        let spill_manager = crate::spill::SpillManager::open(
3901            &durable_root,
3902            crate::spill::SpillConfig::new(resources.temp_disk_budget_bytes),
3903            meta_dek,
3904        )?;
3905        // S1F-002: exactly one durable job registry per core (sibling `JOBS`
3906        // file). Crash recovery inside `open` parks mid-`Running` jobs as
3907        // `Paused` for an operator-driven resume.
3908        let job_registry = Arc::new(crate::jobs::JobRegistry::open(&root, meta_dek.as_ref())?);
3909        let page_cache = Arc::new(crate::cache::Sharded::new(
3910            crate::cache::CACHE_SHARDS,
3911            || {
3912                crate::cache::PageCache::new(
3913                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3914                )
3915                .with_governor(
3916                    memory_governor.clone(),
3917                    crate::memory::MemoryClass::PageCache,
3918                )
3919            },
3920        ));
3921        memory_governor.register_reclaimable(&page_cache);
3922        let decoded_cache = Arc::new(crate::cache::Sharded::new(
3923            crate::cache::CACHE_SHARDS,
3924            || {
3925                crate::cache::DecodedPageCache::new(
3926                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
3927                )
3928                .with_governor(
3929                    memory_governor.clone(),
3930                    crate::memory::MemoryClass::DecodedCache,
3931                )
3932            },
3933        ));
3934        memory_governor.register_reclaimable(&decoded_cache);
3935        let commit_lock = Arc::new(Mutex::new(()));
3936        let shared_wal = Arc::new(Mutex::new(if existing {
3937            crate::wal::SharedWal::open_durable_root_validated(
3938                Arc::clone(&durable_root),
3939                Epoch(cat.db_epoch),
3940                wal_dek.clone(),
3941                Some(&recovery_records),
3942            )?
3943        } else {
3944            crate::wal::SharedWal::create_with_durable_root(
3945                Arc::clone(&durable_root),
3946                Epoch(cat.db_epoch),
3947                wal_dek.clone(),
3948            )?
3949        }));
3950        // Shared write-path state handed to every mounted table so single-table
3951        // `put`/`commit` writes route through the one shared WAL, the one group-
3952        // commit coordinator, and the one poison flag (B1).
3953        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
3954        // S1A-004: the lifecycle is created before the write-path plumbing so
3955        // the group-commit coordinator and every mounted table can poison it on
3956        // an unrecoverable fsync error; `mark_open` happens only once every
3957        // initialization step below has completed.
3958        let lifecycle = Arc::new(crate::core::LifecycleController::new());
3959        let group = Arc::new(
3960            crate::txn::GroupCommit::new(shared_wal.lock().durable_seq())
3961                .with_lifecycle(Arc::clone(&lifecycle)),
3962        );
3963        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
3964        // Final base value is set after the open-generation bump below; tables
3965        // only draw ids once the user issues a write (post-open), so the
3966        // placeholder is never observed.
3967        let txn_ids = Arc::new(Mutex::new(1u64));
3968        let _ = abandoned;
3969        // FND-004 (spec §9.4): the commit-log authority for this database. The
3970        // transaction-id allocator Arc is stable; its base value is re-seeded
3971        // below once the open generation is final. The HLC clock is the node's
3972        // single timestamp authority (spec §4.1, §8.2; ADR-0003): standalone
3973        // mode uses node tiebreaker 0, and the skew bound only engages once
3974        // remote timestamps are observed (Stage 2 replication).
3975        let hlc = Arc::new(mongreldb_types::hlc::HlcClock::new(
3976            0,
3977            std::time::Duration::from_millis(500),
3978        ));
3979        // S1B-005: durable idempotency ledger (sibling `TXN_IDEMPOTENCY` file
3980        // via `DurableRoot::write_atomic`, mirroring `jobs.rs`'s `JOBS`).
3981        let idempotency = crate::txn::IdempotencyLedger::open(Arc::clone(&durable_root), meta_dek)?;
3982        let standalone_commit_log = Arc::new(crate::commit_log::StandaloneCommitLog::new(
3983            Arc::clone(&shared_wal),
3984            Arc::clone(&group),
3985            Arc::clone(&epoch),
3986            Arc::clone(&commit_lock),
3987            Arc::clone(&txn_ids),
3988            root.clone(),
3989            wal_dek.clone(),
3990            Arc::clone(&hlc),
3991        ));
3992        let commit_log: Arc<dyn mongreldb_log::CommitLog> = standalone_commit_log.clone();
3993
3994        // Build the shared auth state early — it's cloned into every mounted
3995        // Table's SharedCtx so the Table layer can enforce permissions without
3996        // a reference back to Database. The `require_auth` flag is mirrored
3997        // from the catalog; `enable_auth` / `refresh_principal` update it live.
3998        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
3999        let security_coordinator = security_coordinator(&root, cat.security_version);
4000        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> =
4001            if matches!(&mode_gate, OpenModeGate::SharedCore) {
4002                // Shared tables are reachable only through DatabaseHandle's
4003                // principal-explicit boundary. A core-wide checker cannot
4004                // represent multiple simultaneous principals.
4005                None
4006            } else {
4007                Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
4008                    auth_state.clone(),
4009                )))
4010            };
4011
4012        // Open every live table against the shared context. Mounted tables have
4013        // no private WAL (B1) — `open_in` just loads the manifest/runs and
4014        // advances the shared epoch authority to its manifest epoch, so the
4015        // final shared watermark is the max across all tables. All of a mounted
4016        // table's committed records are replayed below from the shared WAL.
4017        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
4018        for entry in &cat.tables {
4019            if !matches!(entry.state, TableState::Live) {
4020                continue;
4021            }
4022            let table_root = match table_roots.remove(&entry.table_id) {
4023                Some(root) => root,
4024                None => Arc::new(
4025                    durable_root
4026                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
4027                ),
4028            };
4029            let tdir = table_root.io_path()?;
4030            let ctx = SharedCtx {
4031                root_guard: Some(table_root),
4032                epoch: Arc::clone(&epoch),
4033                page_cache: Arc::clone(&page_cache),
4034                decoded_cache: Arc::clone(&decoded_cache),
4035                snapshots: Arc::clone(&snapshots),
4036                kek: kek.clone(),
4037                commit_lock: Arc::clone(&commit_lock),
4038                shared: Some(crate::engine::SharedWalCtx {
4039                    wal: Arc::clone(&shared_wal),
4040                    group: Arc::clone(&group),
4041                    poisoned: Arc::clone(&poisoned),
4042                    txn_ids: Arc::clone(&txn_ids),
4043                    change_wake: change_wake.clone(),
4044                    lifecycle: Arc::clone(&lifecycle),
4045                    hlc: Arc::clone(&hlc),
4046                }),
4047                table_name: Some(entry.name.clone()),
4048                auth: auth_checker.clone(),
4049                read_only,
4050            };
4051            let t = Table::open_in(&tdir, ctx)?;
4052            tables.insert(entry.table_id, TableHandle::new(t));
4053        }
4054
4055        // Recover transaction writes from the shared WAL (spec §15). This is the
4056        // single durability source for mounted tables: it applies every committed
4057        // record — both single-table `Table::commit` writes and cross-table
4058        // transactions — gated by each table's `flushed_epoch` (records already
4059        // durable in a run are not re-applied).
4060        if existing {
4061            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
4062            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
4063            if read_only {
4064                crate::replication::reconcile_replica_epoch_durable(
4065                    &durable_root,
4066                    epoch.visible().0,
4067                )?;
4068            }
4069            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
4070            // large transactions (spec §8.5, review fix #14).
4071            sweep_pending_txn_dirs(&root, &cat);
4072        }
4073
4074        // Persist only after all semantic recovery and table mounting succeeds.
4075        catalog::write_generation(&durable_root, open_generation)?;
4076        // Storage-mode marker (spec section 5.3): fresh creates record their
4077        // mode; legacy databases (no marker file) are backfilled as Standalone
4078        // on first open. Like the open-generation write above, this is durable
4079        // open bookkeeping, so it also runs for read-only opens.
4080        match &mode_gate {
4081            OpenModeGate::Create(mode) => crate::storage_mode::write(&durable_root, mode)?,
4082            _ if existing && storage_mode.is_none() => {
4083                crate::storage_mode::write(
4084                    &durable_root,
4085                    &crate::storage_mode::StorageMode::Standalone,
4086                )?;
4087            }
4088            _ => {}
4089        }
4090        shared_wal.lock().seal_open_generation(open_generation)?;
4091        crate::replication::replication_identity_durable(&durable_root)?;
4092        let next_txn_id = (open_generation << 32) | 1;
4093        // Seed the shared txn-id allocator now that the generation is final.
4094        *txn_ids.lock() = next_txn_id;
4095        let mut lock = lock;
4096        lock.mark_open()?;
4097
4098        // Initialization is complete: recovery, WAL opening, open-generation
4099        // advancement, and table mounting all happened above, exactly once for
4100        // this core (spec §10.1, S1A-002). The core starts life `Open`.
4101        lifecycle.mark_open();
4102        let core = DatabaseCore {
4103            root,
4104            durable_root,
4105            lifecycle,
4106            registry: std::sync::OnceLock::new(),
4107            read_only,
4108            catalog: RwLock::new(cat),
4109            security_coordinator,
4110            security_catalog_disk_reads: AtomicU64::new(0),
4111            rls_cache: Mutex::new(RlsCache::default()),
4112            epoch,
4113            snapshots,
4114            memory_governor,
4115            page_cache,
4116            decoded_cache,
4117            spill_manager,
4118            resource_groups: crate::resource::ResourceGroupRegistry::with_defaults(),
4119            embedding_providers: crate::embedding::EmbeddingProviderRegistry::new(),
4120            service_principals: crate::service_principal::ServicePrincipalStore::new(),
4121            job_registry,
4122            commit_lock,
4123            kms_rotation_lock: Mutex::new(()),
4124            shared_wal,
4125            next_txn_id: txn_ids,
4126            tables: RwLock::new(tables),
4127            kek,
4128            ddl_lock: Mutex::new(()),
4129            meta_dek,
4130            conflicts: crate::txn::ConflictIndex::new(),
4131            active_txns: crate::txn::ActiveTxns::new(),
4132            lock_manager: Arc::new(crate::locks::LockManager::new()),
4133            poisoned,
4134            group,
4135            commit_log: RwLock::new(commit_log),
4136            standalone_commit_log,
4137            hlc,
4138            idempotency,
4139            commit_ts_ledger: Mutex::new(commit_ts_ledger_from_recovery(&recovery_records)),
4140            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
4141            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
4142            replication_barrier: parking_lot::RwLock::new(()),
4143            replication_wal_retention_segments: AtomicUsize::new(0),
4144            backup_pins: Arc::new(Mutex::new(HashMap::new())),
4145            spill_hook: Mutex::new(None),
4146            security_commit_hook: Mutex::new(None),
4147            catalog_commit_hook: Mutex::new(None),
4148            backup_hook: Mutex::new(None),
4149            fk_lock_hook: Mutex::new(None),
4150            replication_hook: Mutex::new(None),
4151            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
4152            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
4153            trigger_max_loop_iterations: AtomicU32::new(
4154                TriggerConfig::default().max_loop_iterations,
4155            ),
4156            notify: {
4157                let (tx, _rx) = tokio::sync::broadcast::channel(256);
4158                tx
4159            },
4160            change_wake,
4161            _lock: Mutex::new(Some(lock)),
4162        };
4163        Ok(Self {
4164            core: Arc::new(core),
4165            principal: RwLock::new(principal),
4166            auth_state,
4167            shared: false,
4168        })
4169    }
4170
4171    /// The current reader-visible epoch.
4172    pub fn visible_epoch(&self) -> Epoch {
4173        self.epoch.visible()
4174    }
4175
4176    /// The catalog's monotonic command version (S1F-001).
4177    pub fn catalog_version(&self) -> u64 {
4178        self.catalog.read().catalog_version
4179    }
4180
4181    /// The durable storage mode recorded for this database (spec section 5.3).
4182    /// `None` only while a legacy pre-marker database is mid-open (the marker
4183    /// is backfilled before open completes).
4184    pub fn storage_mode(&self) -> Result<Option<crate::storage_mode::StorageMode>> {
4185        Ok(crate::storage_mode::read(&self.durable_root)?)
4186    }
4187
4188    /// The core's node-level memory governor (S1E-003). Exactly one per core;
4189    /// the page caches reserve under it and register as reclaimable.
4190    pub fn memory_governor(&self) -> &crate::memory::MemoryGovernor {
4191        &self.memory_governor
4192    }
4193
4194    /// Workload resource-group registry (S1E-002). Seeded with defaults for
4195    /// every [`crate::resource::WorkloadClass`] at open.
4196    pub fn resource_groups(&self) -> &crate::resource::ResourceGroupRegistry {
4197        &self.resource_groups
4198    }
4199
4200    /// Process-local embedding provider registry. Core storage never hard-codes
4201    /// an external vendor; register local/remote providers here when generation
4202    /// is desired. Application-supplied vectors need no registration.
4203    pub fn embedding_providers(&self) -> &crate::embedding::EmbeddingProviderRegistry {
4204        &self.embedding_providers
4205    }
4206
4207    /// Remove a provider only when no live schema references it.
4208    pub fn unregister_embedding_provider(&self, provider_id: &str) -> Result<bool> {
4209        let references = self
4210            .catalog
4211            .read()
4212            .tables
4213            .iter()
4214            .flat_map(|table| &table.schema.columns)
4215            .filter(|column| {
4216                column
4217                    .embedding_source
4218                    .as_ref()
4219                    .and_then(crate::embedding::EmbeddingSource::provider_id)
4220                    == Some(provider_id)
4221            })
4222            .count();
4223        self.embedding_providers
4224            .unregister_unreferenced(provider_id, references)
4225            .map_err(embedding_error)
4226    }
4227
4228    fn materialize_generated_embeddings(
4229        &self,
4230        staging: &mut [(u64, crate::txn::Staged)],
4231        control: Option<&crate::ExecutionControl>,
4232    ) -> Result<()> {
4233        let schemas = {
4234            let catalog = self.catalog.read();
4235            catalog
4236                .tables
4237                .iter()
4238                .map(|table| (table.table_id, table.schema.clone()))
4239                .collect::<HashMap<_, _>>()
4240        };
4241        let fallback_control = crate::ExecutionControl::new(None);
4242        let control = control.unwrap_or(&fallback_control);
4243        for (table_id, staged) in &mut *staging {
4244            let (cells, mut update_changed_columns) = match staged {
4245                crate::txn::Staged::Put(cells) => (cells, None),
4246                crate::txn::Staged::Update {
4247                    new_row,
4248                    changed_columns,
4249                    ..
4250                } => (new_row, Some(changed_columns)),
4251                crate::txn::Staged::Delete(_) | crate::txn::Staged::Truncate => continue,
4252            };
4253            let schema = schemas.get(table_id).ok_or_else(|| {
4254                MongrelError::Schema(format!("table id {table_id} disappeared during embedding"))
4255            })?;
4256            for target in &schema.columns {
4257                let Some(crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec }) =
4258                    target.embedding_source.as_ref()
4259                else {
4260                    continue;
4261                };
4262                let text = render_embedding_input(schema, spec, cells)?;
4263                let reservation_bytes = text
4264                    .len()
4265                    .saturating_add((spec.dimension as usize).saturating_mul(4));
4266                let _reservation = self
4267                    .memory_governor
4268                    .try_reserve(
4269                        reservation_bytes as u64,
4270                        crate::memory::MemoryClass::AiCandidates,
4271                    )
4272                    .map_err(|error| MongrelError::ResourceLimitExceeded {
4273                        resource: "embedding memory",
4274                        requested: reservation_bytes,
4275                        limit: match error {
4276                            crate::memory::MemoryError::Exhausted { available, .. } => {
4277                                available as usize
4278                            }
4279                            _ => 0,
4280                        },
4281                    })?;
4282                let source =
4283                    crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec: spec.clone() };
4284                let (registry_generation, provider) = self
4285                    .embedding_providers
4286                    .resolve_with_generation(&source)
4287                    .map_err(embedding_error)?;
4288                let semantic_identity = provider.semantic_identity();
4289                self.ensure_ann_semantic_identity_compatible(
4290                    *table_id,
4291                    target.id,
4292                    &semantic_identity,
4293                )?;
4294                let vectors = self
4295                    .embedding_providers
4296                    .embed_controlled(
4297                        &source,
4298                        &[text.as_str()],
4299                        spec.dimension,
4300                        control,
4301                        "generated-column-write",
4302                        crate::embedding::EmbeddingLimits::default(),
4303                    )
4304                    .map_err(embedding_error)?;
4305                cells.retain(|(column, _)| *column != target.id);
4306                cells.push((
4307                    target.id,
4308                    crate::memtable::Value::GeneratedEmbedding(Box::new(
4309                        crate::embedding::GeneratedEmbeddingValue {
4310                            vector: vectors.into_iter().next().ok_or_else(|| {
4311                                MongrelError::Other("embedding provider returned no vector".into())
4312                            })?,
4313                            metadata: crate::embedding::GeneratedEmbeddingMetadata {
4314                                provider_id: provider.provider_id().to_owned(),
4315                                model_id: provider.model_id().to_owned(),
4316                                model_version: provider.model_version().to_owned(),
4317                                preprocessing_version: provider.preprocessing_version().to_owned(),
4318                                source_fingerprint: Sha256::digest(text.as_bytes()).into(),
4319                                status: crate::embedding::EmbeddingGenerationStatus::Ready,
4320                                last_error_category: None,
4321                                attempt_count: 1,
4322                                semantic_identity,
4323                                provider_registry_generation: registry_generation,
4324                            },
4325                        },
4326                    )),
4327                ));
4328                if let Some(changed_columns) = update_changed_columns.as_deref_mut() {
4329                    changed_columns.push(target.id);
4330                }
4331            }
4332            if let Some(changed_columns) = update_changed_columns {
4333                changed_columns.sort_unstable();
4334                changed_columns.dedup();
4335            }
4336        }
4337        self.validate_staged_generated_embedding_identities(staging)?;
4338        Ok(())
4339    }
4340
4341    fn ensure_ann_semantic_identity_compatible(
4342        &self,
4343        table_id: u64,
4344        column_id: u16,
4345        identity: &crate::embedding::EmbeddingProviderRef,
4346    ) -> Result<()> {
4347        let tables = self.tables.read();
4348        let Some(handle) = tables.get(&table_id) else {
4349            return Ok(());
4350        };
4351        let table = handle.lock();
4352        let Some(ann) = table.ann_index(column_id) else {
4353            return Ok(());
4354        };
4355        if let Some(existing) = ann.semantic_identity() {
4356            if existing != identity {
4357                return Err(embedding_error(
4358                    crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
4359                        column_id,
4360                        expected: existing.fingerprint_sha256(),
4361                        got: identity.fingerprint_sha256(),
4362                    },
4363                ));
4364            }
4365        }
4366        Ok(())
4367    }
4368
4369    fn validate_staged_generated_embedding_identities(
4370        &self,
4371        staging: &[(u64, crate::txn::Staged)],
4372    ) -> Result<()> {
4373        for (table_id, staged) in staging {
4374            let cells = match staged {
4375                crate::txn::Staged::Put(cells) => cells.as_slice(),
4376                crate::txn::Staged::Update { new_row, .. } => new_row.as_slice(),
4377                crate::txn::Staged::Delete(_) | crate::txn::Staged::Truncate => continue,
4378            };
4379            for (column_id, value) in cells {
4380                let Some(meta) = value.generated_embedding_metadata() else {
4381                    continue;
4382                };
4383                self.ensure_ann_semantic_identity_compatible(
4384                    *table_id,
4385                    *column_id,
4386                    &meta.semantic_identity,
4387                )?;
4388            }
4389        }
4390        Ok(())
4391    }
4392
4393    /// Acquire exclusive row locks for `SELECT ... FOR UPDATE` (spec §10.2).
4394    /// Locks are held until the transaction ends (`release_txn_locks` /
4395    /// commit / abort). Requires an open transaction id from the SQL session.
4396    pub fn lock_rows_for_update(
4397        &self,
4398        txn_id: u64,
4399        table_id: u64,
4400        row_ids: &[crate::rowid::RowId],
4401        control: Option<&crate::ExecutionControl>,
4402    ) -> Result<()> {
4403        for &row_id in row_ids {
4404            self.acquire_txn_lock(
4405                txn_id,
4406                crate::locks::LockKey::row(table_id, row_id),
4407                crate::locks::LockMode::Exclusive,
4408                control,
4409            )?;
4410        }
4411        Ok(())
4412    }
4413
4414    /// The core's node-level spill manager (S1E-004), rooted at
4415    /// `<db-root>/temp/spill`. Query engines open per-query spill sessions
4416    /// from it.
4417    pub fn spill_manager(&self) -> &crate::spill::SpillManager {
4418        &self.spill_manager
4419    }
4420
4421    /// The core's durable job registry (S1F-002, sibling `JOBS` file).
4422    pub fn job_registry(&self) -> &Arc<crate::jobs::JobRegistry> {
4423        &self.job_registry
4424    }
4425
4426    /// S1C-004 diagnostics: every mounted table's active version-retention
4427    /// pin sources, aggregated from [`crate::engine::Table::version_pins_report`].
4428    /// A version may be reclaimed only when it is older than the oldest pin of
4429    /// every source; this report exposes each of them per table.
4430    pub fn version_pins_report(&self) -> Vec<TablePinsReport> {
4431        let names: HashMap<u64, String> = self
4432            .catalog
4433            .read()
4434            .tables
4435            .iter()
4436            .map(|entry| (entry.table_id, entry.name.clone()))
4437            .collect();
4438        let handles: Vec<_> = self
4439            .tables
4440            .read()
4441            .iter()
4442            .map(|(table_id, handle)| (*table_id, handle.clone()))
4443            .collect();
4444        handles
4445            .into_iter()
4446            .map(|(table_id, handle)| {
4447                let pins = handle.lock().version_pins_report();
4448                TablePinsReport {
4449                    table_id,
4450                    table: names.get(&table_id).cloned().unwrap_or_default(),
4451                    pins,
4452                }
4453            })
4454            .collect()
4455    }
4456
4457    /// P0.5-T6: HLC GC floor with named pin sources, projected through the
4458    /// durable commit-ts ledger when an epoch pin has a stamp.
4459    ///
4460    /// Sources without an active pin or without a durable HLC projection
4461    /// report [`HlcTimestamp::ZERO`](mongreldb_types::hlc::HlcTimestamp::ZERO).
4462    /// Physical reclamation still gates on the epoch floor; this is the HLC
4463    /// diagnostic / cutover surface.
4464    pub fn hlc_gc_floor(&self) -> crate::epoch::GcFloor {
4465        let mut combined = crate::epoch::GcFloor::ZERO;
4466        // Database-level transaction / history pins (shared SnapshotRegistry).
4467        if let Some(epoch) = self.snapshots.min_pinned() {
4468            if let Some(ts) = self.commit_ts_for_epoch(epoch) {
4469                combined.transaction_snapshot = ts;
4470            }
4471        }
4472        if let Some(epoch) = self.snapshots.history_floor(self.epoch.visible()) {
4473            if let Some(ts) = self.commit_ts_for_epoch(epoch) {
4474                combined.history_retention = ts;
4475            }
4476        }
4477        // Merge per-table pin registry projections (min of non-zero).
4478        let handles: Vec<_> = self.tables.read().values().cloned().collect();
4479        for handle in handles {
4480            let table_floor = handle
4481                .lock()
4482                .hlc_gc_floor(|epoch| self.commit_ts_for_epoch(epoch));
4483            for (label, ts) in table_floor.sources() {
4484                if ts == mongreldb_types::hlc::HlcTimestamp::ZERO {
4485                    continue;
4486                }
4487                let slot = match label {
4488                    "transaction_snapshot" => &mut combined.transaction_snapshot,
4489                    "history_retention" => &mut combined.history_retention,
4490                    "backup_pitr" => &mut combined.backup_pitr,
4491                    "replication" => &mut combined.replication,
4492                    "read_generation" => &mut combined.read_generation,
4493                    "online_index_build" => &mut combined.online_index_build,
4494                    _ => continue,
4495                };
4496                if *slot == mongreldb_types::hlc::HlcTimestamp::ZERO || ts < *slot {
4497                    *slot = ts;
4498                }
4499            }
4500        }
4501        combined
4502    }
4503
4504    /// The core's key/predicate lock manager (S1B-003).
4505    pub fn lock_manager(&self) -> &Arc<crate::locks::LockManager> {
4506        &self.lock_manager
4507    }
4508
4509    /// S1B-004 step 12: release every lock `txn_id` holds (commit, abort, and
4510    /// rollback all funnel here). Idempotent — releasing a transaction with
4511    /// no holds is a no-op.
4512    /// Release every lock held by `txn_id` (COMMIT/ROLLBACK/FOR UPDATE end).
4513    pub fn release_txn_locks(&self, txn_id: u64) {
4514        self.lock_manager.release_all(txn_id);
4515    }
4516
4517    /// Build a lock request for `txn_id`, inheriting the commit's
4518    /// cancellation control (or a plain one when the commit is uncontrolled).
4519    fn txn_lock_request(
4520        txn_id: u64,
4521        mode: crate::locks::LockMode,
4522        control: Option<&crate::ExecutionControl>,
4523    ) -> crate::locks::LockRequest {
4524        let control = control
4525            .cloned()
4526            .unwrap_or_else(|| crate::ExecutionControl::new(None));
4527        crate::locks::LockRequest::new(txn_id, mode, control)
4528    }
4529
4530    /// Acquire one lock for `txn_id`, bridging the typed [`crate::locks::LockError`]
4531    /// onto the engine error (deadlock victims surface as
4532    /// [`MongrelError::Deadlock`] with [`mongreldb_types::error::ErrorCategory::Deadlock`]).
4533    pub(crate) fn acquire_txn_lock(
4534        &self,
4535        txn_id: u64,
4536        key: crate::locks::LockKey,
4537        mode: crate::locks::LockMode,
4538        control: Option<&crate::ExecutionControl>,
4539    ) -> Result<()> {
4540        self.lock_manager
4541            .acquire(key, Self::txn_lock_request(txn_id, mode, control))
4542            .map_err(MongrelError::from)
4543    }
4544
4545    /// S1B-003: acquire the schema barrier Exclusive for one DDL operation.
4546    /// The hold releases when the returned guard drops (end of the DDL entry
4547    /// point). DML transactions hold the barrier Shared for their whole
4548    /// commit, so schema changes exclude concurrent DML and one another.
4549    fn acquire_schema_barrier_exclusive(&self) -> Result<TxnLockGuard<'_>> {
4550        let txn_id = self.alloc_txn_id()?;
4551        self.acquire_txn_lock(
4552            txn_id,
4553            crate::locks::LockKey::schema_barrier(),
4554            crate::locks::LockMode::Exclusive,
4555            None,
4556        )?;
4557        Ok(TxnLockGuard {
4558            locks: &self.lock_manager,
4559            txn_id,
4560        })
4561    }
4562
4563    /// The commit-log authority for this database (spec §9.4, FND-004). Every
4564    /// commit path proposes through it and visibility publication is gated on
4565    /// its receipts; Stage 2 swaps this standalone adapter for the replicated
4566    /// implementation behind the same `CommitLog` trait.
4567    pub fn commit_log(&self) -> Arc<dyn mongreldb_log::CommitLog> {
4568        Arc::clone(&self.commit_log.read())
4569    }
4570
4571    /// Bind a cluster replica core to its owning consensus commit authority.
4572    ///
4573    /// Standalone roots reject substitution. Cluster replica user mutations
4574    /// remain read-only; this binding exposes the same authoritative log to
4575    /// runtime diagnostics and future proposal surfaces while committed apply
4576    /// stays the only engine mutation path.
4577    pub fn bind_cluster_commit_log(
4578        &self,
4579        commit_log: Arc<dyn mongreldb_log::CommitLog>,
4580    ) -> Result<()> {
4581        match self.storage_mode()? {
4582            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4583                *self.commit_log.write() = commit_log;
4584                Ok(())
4585            }
4586            mode => Err(MongrelError::InvalidArgument(format!(
4587                "commit-log substitution requires ClusterReplica storage, got {mode:?}"
4588            ))),
4589        }
4590    }
4591
4592    /// Break the cluster commit-log binding during replica-runtime teardown.
4593    ///
4594    /// The replacement standalone adapter is retained only as an inert
4595    /// lifecycle anchor. Cluster replica writes remain rejected, and a
4596    /// restarted runtime must bind its new Raft authority before serving.
4597    pub fn detach_cluster_commit_log(&self) -> Result<()> {
4598        match self.storage_mode()? {
4599            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4600                let standalone: Arc<dyn mongreldb_log::CommitLog> =
4601                    self.standalone_commit_log.clone();
4602                *self.commit_log.write() = standalone;
4603                Ok(())
4604            }
4605            mode => Err(MongrelError::InvalidArgument(format!(
4606                "commit-log detachment requires ClusterReplica storage, got {mode:?}"
4607            ))),
4608        }
4609    }
4610
4611    /// The node's HLC timestamp authority (spec §8.2, ADR-0003). Transaction
4612    /// `begin` captures read timestamps here; the commit sequencer allocates
4613    /// commit timestamps from the same clock so commit ts > every participant
4614    /// read/write timestamp of the transaction.
4615    /// Shared HLC clock for this core (P0.5 / cluster apply stamping).
4616    pub fn hlc(&self) -> &mongreldb_types::hlc::HlcClock {
4617        self.hlc_clock()
4618    }
4619
4620    pub(crate) fn hlc_clock(&self) -> &mongreldb_types::hlc::HlcClock {
4621        &self.hlc
4622    }
4623
4624    /// Clone the in-memory catalog (for diagnostics / tests).
4625    pub fn catalog_snapshot(&self) -> Catalog {
4626        self.catalog.read().clone()
4627    }
4628
4629    /// Read SQLite-compatible application metadata persisted in the catalog.
4630    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
4631        let catalog = self.catalog.read();
4632        match key {
4633            "user_version" => Ok(catalog.user_version),
4634            "application_id" => Ok(catalog.application_id),
4635            _ => Err(MongrelError::InvalidArgument(format!(
4636                "unsupported persistent SQL pragma {key:?}"
4637            ))),
4638        }
4639    }
4640
4641    /// Persist SQLite-compatible application metadata and return its exact
4642    /// publication epoch. An unchanged value performs no durable write.
4643    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
4644        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
4645    }
4646
4647    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
4648        &self,
4649        key: &str,
4650        value: i64,
4651        mut before_commit: F,
4652    ) -> Result<Option<Epoch>>
4653    where
4654        F: FnMut() -> Result<()>,
4655    {
4656        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
4657    }
4658
4659    fn set_sql_pragma_i64_with_epoch_inner(
4660        &self,
4661        key: &str,
4662        value: i64,
4663        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
4664    ) -> Result<Option<Epoch>> {
4665        use crate::wal::DdlOp;
4666
4667        self.require(&crate::auth::Permission::Ddl)?;
4668        if self.read_only {
4669            return Err(MongrelError::ReadOnlyReplica);
4670        }
4671        if self.poisoned.load(Ordering::Relaxed) {
4672            return Err(MongrelError::Other(
4673                "database poisoned by fsync error".into(),
4674            ));
4675        }
4676        let _ddl = self.ddl_lock.lock();
4677        let _security_write = self.security_write()?;
4678        self.require(&crate::auth::Permission::Ddl)?;
4679        let mut next_catalog = self.catalog.read().clone();
4680        let target = match key {
4681            "user_version" => &mut next_catalog.user_version,
4682            "application_id" => &mut next_catalog.application_id,
4683            _ => {
4684                return Err(MongrelError::InvalidArgument(format!(
4685                    "unsupported persistent SQL pragma {key:?}"
4686                )))
4687            }
4688        };
4689        if *target == Some(value) {
4690            return Ok(None);
4691        }
4692        *target = Some(value);
4693
4694        let _commit = self.commit_lock.lock();
4695        let epoch = self.epoch.bump_assigned();
4696        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4697        let txn_id = self.alloc_txn_id()?;
4698        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4699        let commit_seq = {
4700            let mut wal = self.shared_wal.lock();
4701            if let Some(before_commit) = before_commit {
4702                before_commit()?;
4703            }
4704            let append: Result<u64> = (|| {
4705                wal.append(
4706                    txn_id,
4707                    WAL_TABLE_ID,
4708                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
4709                        key: key.to_string(),
4710                        value,
4711                    }),
4712                )?;
4713                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4714                wal.append_commit(txn_id, epoch, &[])
4715            })();
4716            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4717        };
4718        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4719        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4720        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
4721        Ok(Some(epoch))
4722    }
4723
4724    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
4725        self.catalog
4726            .read()
4727            .materialized_views
4728            .iter()
4729            .find(|definition| definition.name == name)
4730            .cloned()
4731    }
4732
4733    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
4734        self.catalog.read().materialized_views.clone()
4735    }
4736
4737    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
4738        self.catalog.read().security.clone()
4739    }
4740
4741    pub fn security_active_for(&self, table: &str) -> bool {
4742        self.catalog.read().security.table_has_security(table)
4743    }
4744
4745    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
4746        if self.catalog.read().security_version == expected_version {
4747            return Ok(());
4748        }
4749        self.security_catalog_disk_reads
4750            .fetch_add(1, Ordering::Relaxed);
4751        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
4752            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
4753        let principal = self.principal.read().clone();
4754        let principal = if fresh.require_auth
4755            && principal
4756                .as_ref()
4757                .is_some_and(|principal| principal.user_id != 0)
4758        {
4759            principal
4760                .as_ref()
4761                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
4762        } else {
4763            principal
4764        };
4765        self.auth_state.set_require_auth(fresh.require_auth);
4766        *self.catalog.write() = fresh;
4767        *self.principal.write() = principal.clone();
4768        self.auth_state.set_principal(principal);
4769        Ok(())
4770    }
4771
4772    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
4773        let guard = self.security_coordinator.gate.write();
4774        let version = self.security_coordinator.version.load(Ordering::Acquire);
4775        self.refresh_security_catalog_if_stale(version)?;
4776        Ok(guard)
4777    }
4778
4779    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
4780    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
4781    /// only its restart checkpoint.
4782    /// S1A-004: admit one operation against the core (rejects once the core
4783    /// leaves [`crate::core::LifecycleState::Open`] — draining, closing,
4784    /// closed, or poisoned). The returned guard holds the operation slot
4785    /// until dropped, so `shutdown()` drains exactly the covered operations.
4786    ///
4787    /// Covered set (the mutating/durable paths; the legacy fsync-poison error
4788    /// still wins where a body already checks `self.poisoned`, because the
4789    /// guard is taken after that check):
4790    ///
4791    /// - the cross-table commit funnel
4792    ///   (`commit_transaction_with_external_states_inner`), covering every
4793    ///   user and internal transaction commit;
4794    /// - the catalog publish funnel (`publish_catalog_candidate_with_prelude`),
4795    ///   covering the user/role/grant, trigger, and procedure families;
4796    /// - the inline-WAL DDL bodies: table create/drop/rename/alter, CTAS
4797    ///   rebuilding publish, security-catalog and materialized-view
4798    ///   replacement, external-table create/drop/reset;
4799    /// - storage maintenance: `gc`, `checkpoint`, `compact`, `hot_backup`;
4800    /// - replication bootstrap, batch extraction, and follow-apply.
4801    ///
4802    /// Read-only entry points (snapshots, queries, stats) and the infallible
4803    /// `begin*` constructors are intentionally not covered: reads never block
4804    /// shutdown, and a transaction's durable act is its commit.
4805    pub(crate) fn admit_operation(&self) -> Result<crate::core::OperationGuard> {
4806        self.core.operation_guard()
4807    }
4808
4809    /// S1F-001: wrap `command` in its versioned record and apply it to the
4810    /// catalog candidate (validate → mutate → bump `catalog_version` → append
4811    /// the bounded command history). Security-catalog changes advance the
4812    /// security version inside `CatalogDelta::apply_to`, exactly where the
4813    /// legacy mutation bodies called `advance_security_version` themselves.
4814    fn apply_catalog_command_to(
4815        &self,
4816        next_catalog: &mut Catalog,
4817        command: crate::catalog_cmds::CatalogCommand,
4818    ) -> Result<crate::catalog_cmds::CatalogDelta> {
4819        let record = crate::catalog_cmds::CatalogCommandRecord::next(next_catalog, command);
4820        next_catalog.apply_command(&record)
4821    }
4822
4823    /// Stage 2E (spec section 11.5): apply one committed replicated
4824    /// transaction's staged records through the **same** logic the WAL
4825    /// recovery path uses ([`recover_shared_wal`]).
4826    ///
4827    /// The payload is a complete record sequence of one committed transaction
4828    /// (data ops, `Op::CommitTimestamp`, and exactly one trailing
4829    /// `Op::TxnCommit`), carrying the leader-assigned commit epoch so every
4830    /// replica applies byte-identical records deterministically.
4831    ///
4832    /// Application is durable before it returns: the records are appended
4833    /// verbatim to the core's shared WAL and group-synced, so the raft state
4834    /// machine's post-apply checkpoint never acknowledges rows a crash would
4835    /// lose. Application is idempotent: a payload whose commit epoch is at or
4836    /// below the core's visible watermark is a replay (the state machine
4837    /// dispatches sink-first, checkpoints second — a crash in that window
4838    /// redelivers) and is skipped without side effects. Returns `Ok(true)`
4839    /// when the payload was applied, `Ok(false)` for a recognized replay.
4840    pub fn apply_replicated_records(&self, records: &[crate::wal::Record]) -> Result<bool> {
4841        use crate::wal::Op;
4842        use std::sync::atomic::Ordering;
4843
4844        let _operation = self.admit_operation()?;
4845        if self.poisoned.load(Ordering::Relaxed) {
4846            return Err(MongrelError::Other(
4847                "database poisoned by fsync error".into(),
4848            ));
4849        }
4850        // Structural validation (fail closed): one transaction, exactly one
4851        // commit marker, at the tail.
4852        let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
4853            MongrelError::InvalidArgument("replicated transaction payload is empty".into())
4854        })?;
4855        if records.iter().any(|record| record.txn_id != txn_id) {
4856            return Err(MongrelError::InvalidArgument(
4857                "replicated transaction payload mixes transaction ids".into(),
4858            ));
4859        }
4860        let commits = records
4861            .iter()
4862            .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
4863            .count();
4864        if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
4865            return Err(MongrelError::InvalidArgument(
4866                "replicated transaction payload must end in exactly one commit marker".into(),
4867            ));
4868        }
4869        let commit_epoch = match records.last().map(|r| &r.op) {
4870            Some(Op::TxnCommit { epoch, .. }) => *epoch,
4871            _ => unreachable!("validated above"),
4872        };
4873        // Fail closed on spilled-run linking: an `added_runs` commit links
4874        // run files that exist only on the leader. Leaders never emit such a
4875        // payload — the Stage 2C envelope builder passes every commit through
4876        // [`translate_records_for_replication`], which stages the spilled rows
4877        // as logical `Op::Put` records and strips the run links (spec section
4878        // 11.3 step 3). This check is the defense-in-depth gate behind that
4879        // contract: a payload carrying run references is un-appliable here
4880        // and is rejected deterministically rather than diverging.
4881        if let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) {
4882            if !added_runs.is_empty() {
4883                return Err(MongrelError::InvalidArgument(
4884                    "replicated spilled-run commits are not appliable: the leader must translate \
4885                     the commit through translate_records_for_replication before proposal"
4886                        .into(),
4887                ));
4888            }
4889        }
4890        // Replay guard: the leader assigns one monotonically increasing epoch
4891        // per committed transaction in log order, so anything at or below the
4892        // core's watermark is already applied.
4893        if commit_epoch <= self.epoch.visible().0 {
4894            return Ok(false);
4895        }
4896        // Stage durable first: verbatim WAL append + fsync. The raft log is
4897        // the commit authority; the local WAL is this replica's durable
4898        // staging of already-committed commands (restart recovery replays it
4899        // through the identical path, gated by flushed epochs).
4900        {
4901            let mut wal = self.shared_wal.lock();
4902            for record in records {
4903                wal.append(record.txn_id, 0, record.op.clone())?;
4904            }
4905            if let Err(error) = wal.group_sync() {
4906                self.poisoned.store(true, Ordering::Relaxed);
4907                return Err(error);
4908            }
4909        }
4910        // S1C-004: pin the pre-apply visible epoch for the duration of apply so
4911        // concurrent GC cannot reclaim versions a catch-up reader still needs.
4912        let replication_pins: Vec<crate::retention::PinGuard> = {
4913            let tables = self.tables.read();
4914            let floor = Epoch(self.epoch.visible().0);
4915            tables
4916                .values()
4917                .map(|handle| {
4918                    let t = handle.lock();
4919                    Arc::clone(t.pin_registry())
4920                        .pin(crate::retention::PinSource::Replication, floor)
4921                })
4922                .collect()
4923        };
4924        let tables = self.tables.read().clone();
4925        let catalog = self.catalog.read().clone();
4926        recover_shared_wal(&self.durable_root, &tables, &catalog, &self.epoch, records)?;
4927        // Replica-side commit-ts ledger: record the leader-assigned commit
4928        // epoch's physical time from any CommitTimestamp op, else stamp from
4929        // the local HLC so `commit_ts_for_epoch` serves PITR/read-your-writes
4930        // on replicas (Stage 2 residual).
4931        if let Some(Op::TxnCommit { epoch, .. }) = records.last().map(|r| &r.op) {
4932            let commit_ts = records
4933                .iter()
4934                .rev()
4935                .find_map(|record| match &record.op {
4936                    Op::CommitTimestamp { unix_nanos } => {
4937                        Some(mongreldb_types::hlc::HlcTimestamp {
4938                            physical_micros: unix_nanos / 1_000,
4939                            logical: 0,
4940                            node_tiebreaker: 0,
4941                        })
4942                    }
4943                    _ => None,
4944                })
4945                .unwrap_or_else(|| {
4946                    self.hlc
4947                        .now()
4948                        .unwrap_or(mongreldb_types::hlc::HlcTimestamp {
4949                            physical_micros: 0,
4950                            logical: 0,
4951                            node_tiebreaker: 0,
4952                        })
4953                });
4954            self.record_commit_ts(Epoch(*epoch), commit_ts);
4955        }
4956        drop(replication_pins);
4957        Ok(true)
4958    }
4959
4960    /// Stage 3H engine binding (spec section 12.8): validate staged
4961    /// write-intent payloads at prepare time. A participant that durably
4962    /// prepares these payloads must be able to apply them at resolution, so
4963    /// every check the resolution apply performs runs here first: each
4964    /// payload decodes as a [`StagedTxnWrite`], its table is mounted, and
4965    /// every staged row satisfies the same persisted-row validation the WAL
4966    /// recovery path applies. A malformed payload is rejected deterministically
4967    /// at prepare — never after a decision commits, where a rejection would
4968    /// wedge the replicated apply stream.
4969    pub fn validate_staged_txn_writes(&self, staged: &[Vec<u8>]) -> Result<()> {
4970        let tables = self.tables.read();
4971        for payload in staged {
4972            match StagedTxnWrite::decode(payload)? {
4973                StagedTxnWrite::Put { table_id, rows } => {
4974                    let handle = tables.get(&table_id).ok_or_else(|| {
4975                        MongrelError::InvalidArgument(format!(
4976                            "staged write targets unmounted table {table_id}"
4977                        ))
4978                    })?;
4979                    let rows: Vec<crate::memtable::Row> =
4980                        bincode::deserialize(&rows).map_err(|error| {
4981                            MongrelError::InvalidArgument(format!(
4982                                "staged put payload for table {table_id} cannot decode: {error}"
4983                            ))
4984                        })?;
4985                    let schema = handle.lock().schema().clone();
4986                    for row in &rows {
4987                        validate_recovered_row(&schema, row).map_err(|error| {
4988                            MongrelError::InvalidArgument(format!(
4989                                "staged row for table {table_id} is not appliable: {error}"
4990                            ))
4991                        })?;
4992                    }
4993                }
4994                StagedTxnWrite::Delete { table_id, row_ids } => {
4995                    if !tables.contains_key(&table_id) {
4996                        return Err(MongrelError::InvalidArgument(format!(
4997                            "staged delete targets unmounted table {table_id}"
4998                        )));
4999                    }
5000                    if row_ids.contains(&u64::MAX) {
5001                        return Err(MongrelError::InvalidArgument(format!(
5002                            "staged delete for table {table_id} names an exhausted row id"
5003                        )));
5004                    }
5005                }
5006            }
5007        }
5008        Ok(())
5009    }
5010
5011    /// Stage 3H engine binding (spec section 12.8): apply one committed
5012    /// resolution's staged writes through the replicated apply path
5013    /// ([`Database::apply_replicated_records`]) at the decision's commit
5014    /// timestamp. `txn_tag` is the caller's deterministic per-transaction
5015    /// tag (e.g. a hash of the distributed transaction id); it must be
5016    /// identical on every replica. The synthetic WAL transaction id is
5017    /// derived from it inside the current open-generation namespace with the
5018    /// allocator-avoidance bit set, so resolution records pass the
5019    /// open-generation durability check on reopen and never alias
5020    /// leader-allocated transaction ids.
5021    ///
5022    /// The commit epoch stamped into the synthetic commit marker is one past
5023    /// the core's visible watermark: the replicated apply stream is
5024    /// deterministic, so every replica computes the identical epoch at the
5025    /// identical apply point, and the core's own restart recovery replays the
5026    /// stamped sequence verbatim. Rows are restamped at that epoch by the
5027    /// recovery logic, becoming visible exactly as an ordinary committed
5028    /// transaction's rows.
5029    ///
5030    /// Alias: [`Self::apply_committed_transaction`] — same semantics under the
5031    /// audit's apply-API name (P0.5-T4).
5032    pub fn apply_staged_txn_writes(
5033        &self,
5034        txn_tag: u64,
5035        staged: &[Vec<u8>],
5036        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5037    ) -> Result<bool> {
5038        use crate::wal::Op;
5039
5040        // Decode every payload before any mutation (fail closed).
5041        let mut writes = Vec::with_capacity(staged.len());
5042        for payload in staged {
5043            writes.push(StagedTxnWrite::decode(payload)?);
5044        }
5045        // Generation-namespace the synthetic transaction id: the high 32 bits
5046        // are the current open generation (the reopen path rejects retained
5047        // records from a generation beyond the durable WAL head); the low 32
5048        // bits carry the caller's tag with the top bit set, outside the
5049        // ascending leader-allocated counter space.
5050        let generation = *self.next_txn_id.lock() >> 32;
5051        let txn_id = (generation << 32) | (txn_tag & 0x7FFF_FFFF) | 0x8000_0000;
5052        let mut records = Vec::with_capacity(writes.len() + 2);
5053        for write in writes {
5054            let op = match write {
5055                StagedTxnWrite::Put { table_id, rows } => {
5056                    // `Row::commit_ts` is `#[serde(skip)]` (0.63.1 WAL bincode
5057                    // layout), so the shared decision HLC cannot ride the Put
5058                    // payload. It travels in the trailing `Op::CommitTimestamp`
5059                    // record (physical micros) and is restamped onto every row
5060                    // version by the apply/recovery path, keeping live apply
5061                    // and WAL recovery on the identical stamp.
5062                    let decoded: Vec<crate::memtable::Row> =
5063                        bincode::deserialize(&rows).map_err(|e| {
5064                            MongrelError::Other(format!(
5065                                "staged txn put rows could not be decoded: {e}"
5066                            ))
5067                        })?;
5068                    let stamped = bincode::serialize(&decoded).map_err(|e| {
5069                        MongrelError::Other(format!("staged txn put rows serialize: {e}"))
5070                    })?;
5071                    Op::Put {
5072                        table_id,
5073                        rows: stamped,
5074                    }
5075                }
5076                StagedTxnWrite::Delete { table_id, row_ids } => Op::Delete {
5077                    table_id,
5078                    row_ids: row_ids.into_iter().map(crate::RowId).collect(),
5079                },
5080            };
5081            records.push(crate::wal::Record::new(Epoch(0), txn_id, op));
5082        }
5083        let epoch = self.epoch.visible().0 + 1;
5084        // The physical component of the decision's commit timestamp goes into
5085        // the durable timestamp ledger, mirroring the ordinary commit path
5086        // (`commit_log::commit_nanos`).
5087        let unix_nanos = commit_ts.physical_micros.saturating_mul(1_000);
5088        records.push(crate::wal::Record::new(
5089            Epoch(0),
5090            txn_id,
5091            Op::CommitTimestamp { unix_nanos },
5092        ));
5093        records.push(crate::wal::Record::new(
5094            Epoch(0),
5095            txn_id,
5096            Op::TxnCommit {
5097                epoch,
5098                added_runs: Vec::new(),
5099            },
5100        ));
5101        let applied = self.apply_replicated_records(&records)?;
5102        if applied && commit_ts != mongreldb_types::hlc::HlcTimestamp::ZERO {
5103            // Ledger + recovery restamp use the shared decision HLC so every
5104            // replica observes the identical commit_ts (P0.5-T4 / P0.5-X1).
5105            self.record_commit_ts(Epoch(epoch), commit_ts);
5106        }
5107        Ok(applied)
5108    }
5109
5110    /// Apply a committed distributed transaction at a shared `commit_ts`
5111    /// (P0.5-T4). Replicas must not allocate a local replacement timestamp —
5112    /// the caller's decision HLC goes into the durable ledger, and every row
5113    /// version recovered from the synthetic WAL records is restamped from the
5114    /// transaction's `Op::CommitTimestamp` record (physical micros; the WAL
5115    /// `Put` payload cannot carry the full HLC under the 0.63.1 layout).
5116    pub fn apply_committed_transaction(
5117        &self,
5118        transaction_id: u64,
5119        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5120        operations: &[Vec<u8>],
5121    ) -> Result<bool> {
5122        self.apply_staged_txn_writes(transaction_id, operations, commit_ts)
5123    }
5124
5125    /// Stage 2E (spec sections 10.6, 11.5): apply one committed replicated
5126    /// catalog command and checkpoint the catalog. The record travels as the
5127    /// payload of a replicated `Catalog` command envelope and routes through
5128    /// [`Catalog::apply_command`] — the S1F-001 versioned, idempotent command
5129    /// path (replaying an already-applied `catalog_version` is a no-op).
5130    /// Structural deltas are mirrored into the mounted table set: a created
5131    /// table is mounted, a dropped table unmounted. Deterministic: the record
5132    /// carries every resolved value (ids, epochs, complete images).
5133    pub fn apply_replicated_catalog_command(
5134        &self,
5135        record: &crate::catalog_cmds::CatalogCommandRecord,
5136    ) -> Result<crate::catalog_cmds::CatalogDelta> {
5137        let _operation = self.admit_operation()?;
5138        let _g = self.ddl_lock.lock();
5139        let mut next_catalog = self.catalog.read().clone();
5140        let delta = next_catalog.apply_command(record)?;
5141        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
5142            return Ok(delta);
5143        }
5144        // The leader references epochs in structural commands (a table's
5145        // creation/drop epoch). The replica's epoch stream must cover them:
5146        // epochs stay the commit sequencer's authority, so commands never
5147        // allocate one, but the watermark advances to every referenced epoch
5148        // (mirroring how `create_table_with_state` bumps `db_epoch`).
5149        let referenced_epoch = match &delta {
5150            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => Some(entry.created_epoch),
5151            crate::catalog_cmds::CatalogDelta::TableDropped { at_epoch, .. }
5152            | crate::catalog_cmds::CatalogDelta::TableRenamed { at_epoch, .. } => Some(*at_epoch),
5153            _ => None,
5154        };
5155        if let Some(referenced) = referenced_epoch {
5156            self.epoch.advance_recovered(Epoch(referenced));
5157            next_catalog.db_epoch = next_catalog.db_epoch.max(referenced);
5158        }
5159        match &delta {
5160            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => {
5161                // Guard against a repeated mount within one open (the state
5162                // machine's crash-window redispatch is filtered by the NoOp
5163                // arm above; this covers a catalog checkpoint that never
5164                // became durable before a crash).
5165                if !self.tables.read().contains_key(&entry.table_id) {
5166                    self.mount_catalog_entry(entry)?;
5167                }
5168            }
5169            crate::catalog_cmds::CatalogDelta::TableDropped { table_id, .. } => {
5170                self.tables.write().remove(table_id);
5171            }
5172            _ => {}
5173        }
5174        // Durable BEFORE return: the catalog checkpoint is the only local
5175        // durable record of the command (replicated catalog commands do not
5176        // ride the local WAL), and the state machine checkpoints right after.
5177        catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref())?;
5178        *self.catalog.write() = next_catalog;
5179        Ok(delta)
5180    }
5181
5182    /// Mount a table that a replicated catalog command created (Stage 2E):
5183    /// create the table directory and build the mounted table exactly like
5184    /// the create-table path does, minus the standalone WAL/commit side
5185    /// effects (the command itself is already durable in the raft log).
5186    fn mount_catalog_entry(&self, entry: &crate::catalog::CatalogEntry) -> Result<()> {
5187        let table_relative = Path::new(TABLES_DIR).join(entry.table_id.to_string());
5188        let table_root = Arc::new(
5189            self.durable_root
5190                .create_directory_all_pinned(&table_relative)?,
5191        );
5192        let tdir = table_root.io_path()?;
5193        let ctx = SharedCtx {
5194            root_guard: Some(table_root),
5195            epoch: Arc::clone(&self.epoch),
5196            page_cache: Arc::clone(&self.page_cache),
5197            decoded_cache: Arc::clone(&self.decoded_cache),
5198            snapshots: Arc::clone(&self.snapshots),
5199            kek: self.kek.clone(),
5200            commit_lock: Arc::clone(&self.commit_lock),
5201            shared: Some(crate::engine::SharedWalCtx {
5202                wal: Arc::clone(&self.shared_wal),
5203                group: Arc::clone(&self.group),
5204                poisoned: Arc::clone(&self.poisoned),
5205                txn_ids: Arc::clone(&self.next_txn_id),
5206                change_wake: self.change_wake.clone(),
5207                lifecycle: Arc::clone(&self.lifecycle),
5208                hlc: Arc::clone(&self.hlc),
5209            }),
5210            table_name: Some(entry.name.clone()),
5211            auth: self.table_auth_checker(),
5212            read_only: self.read_only,
5213        };
5214        let table = Table::create_in(&tdir, entry.schema.clone(), entry.table_id, ctx)?;
5215        self.tables
5216            .write()
5217            .insert(entry.table_id, TableHandle::new(table));
5218        Ok(())
5219    }
5220
5221    fn publish_catalog_candidate(
5222        &self,
5223        catalog: Catalog,
5224        epoch: Epoch,
5225        epoch_guard: &mut EpochGuard<'_>,
5226        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5227    ) -> Result<()> {
5228        self.publish_catalog_candidate_with_prelude(
5229            catalog,
5230            epoch,
5231            epoch_guard,
5232            before_publish,
5233            Vec::new(),
5234        )
5235    }
5236
5237    fn publish_catalog_candidate_with_prelude(
5238        &self,
5239        catalog: Catalog,
5240        epoch: Epoch,
5241        epoch_guard: &mut EpochGuard<'_>,
5242        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5243        prelude: Vec<(u64, crate::wal::Op)>,
5244    ) -> Result<()> {
5245        use crate::wal::DdlOp;
5246
5247        if self.read_only {
5248            return Err(MongrelError::ReadOnlyReplica);
5249        }
5250        if self.poisoned.load(Ordering::Relaxed) {
5251            return Err(MongrelError::Other(
5252                "database poisoned by fsync error".into(),
5253            ));
5254        }
5255        // S1A-004: admit the catalog publish as one core operation.
5256        let _operation = self.admit_operation()?;
5257        if let Some(before_publish) = before_publish.as_mut() {
5258            (**before_publish)()?;
5259        }
5260        if catalog.db_epoch != epoch.0 {
5261            return Err(MongrelError::InvalidArgument(format!(
5262                "catalog epoch {} does not match commit epoch {}",
5263                catalog.db_epoch, epoch.0
5264            )));
5265        }
5266        {
5267            let current = self.catalog.read();
5268            validate_catalog_transition(&current, &catalog)?;
5269        }
5270        validate_recovered_catalog(&catalog)?;
5271        let catalog_json = DdlOp::encode_catalog(&catalog)?;
5272        let txn_id = self.alloc_txn_id()?;
5273        let commit_seq = {
5274            let mut wal = self.shared_wal.lock();
5275            let append: Result<u64> = (|| {
5276                for (table_id, op) in prelude {
5277                    wal.append(txn_id, table_id, op)?;
5278                }
5279                wal.append(
5280                    txn_id,
5281                    WAL_TABLE_ID,
5282                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
5283                )?;
5284                wal.append_commit(txn_id, epoch, &[])
5285            })();
5286            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5287        };
5288        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5289        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
5290        self.finish_durable_publish(epoch, epoch_guard, &receipt, checkpoint)
5291    }
5292
5293    /// A WAL commit is already durable. Publish the matching catalog in memory
5294    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
5295    /// while the live handle must never continue with pre-commit metadata.
5296    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
5297        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
5298        let version = catalog.security_version;
5299        let principal = self.principal.read().clone();
5300        let principal = if catalog.require_auth
5301            && principal
5302                .as_ref()
5303                .is_some_and(|principal| principal.user_id != 0)
5304        {
5305            principal.as_ref().and_then(|principal| {
5306                Self::resolve_bound_principal_from_catalog(&catalog, principal)
5307            })
5308        } else {
5309            principal
5310        };
5311        *self.catalog.write() = catalog;
5312        self.security_coordinator
5313            .version
5314            .store(version, Ordering::Release);
5315        self.auth_state
5316            .set_require_auth(self.catalog.read().require_auth);
5317        *self.principal.write() = principal.clone();
5318        self.auth_state.set_principal(principal);
5319        checkpoint
5320    }
5321
5322    fn finish_durable_publish(
5323        &self,
5324        epoch: Epoch,
5325        epoch_guard: &mut EpochGuard<'_>,
5326        receipt: &mongreldb_log::CommitReceipt,
5327        post_step: Result<()>,
5328    ) -> Result<()> {
5329        if let Err(error) = self.publish_committed(receipt, epoch) {
5330            // The commit marker is durable but runtime publication failed. The
5331            // epoch guard stays armed so the assigned ticket is abandoned (the
5332            // watermark skips it), and the live handle poisons exactly like any
5333            // other post-durable failure.
5334            self.poisoned.store(true, Ordering::Relaxed);
5335            self.lifecycle.poison();
5336            return Err(MongrelError::DurableCommit {
5337                epoch: epoch.0,
5338                message: error.to_string(),
5339            });
5340        }
5341        epoch_guard.disarm();
5342        match post_step {
5343            Ok(()) => Ok(()),
5344            Err(error) => {
5345                self.poisoned.store(true, Ordering::Relaxed);
5346                self.lifecycle.poison();
5347                Err(MongrelError::DurableCommit {
5348                    epoch: epoch.0,
5349                    message: error.to_string(),
5350                })
5351            }
5352        }
5353    }
5354
5355    /// Advance reader visibility for a committed epoch. Publication is gated on
5356    /// the commit log's receipt (spec §9.4, FND-004): visibility only ever
5357    /// covers commands the commit log acknowledged durable. The
5358    /// `commit.publish.before`/`commit.publish.after` fault hooks bracket the
5359    /// watermark advance (spec §9.6, FND-006).
5360    fn publish_committed(
5361        &self,
5362        receipt: &mongreldb_log::CommitReceipt,
5363        epoch: Epoch,
5364    ) -> Result<()> {
5365        debug_assert_eq!(
5366            receipt.log_position.index, epoch.0,
5367            "commit receipt position must match the published epoch"
5368        );
5369        mongreldb_fault::inject("commit.publish.before").map_err(crate::commit_log::fault_as_io)?;
5370        self.epoch.publish_in_order(epoch);
5371        mongreldb_fault::inject("commit.publish.after").map_err(crate::commit_log::fault_as_io)?;
5372        Ok(())
5373    }
5374
5375    /// Wait for a commit marker to reach stable storage and return the commit
5376    /// log's irrevocable receipt (spec §9.4, FND-004). A failed append/fsync
5377    /// acknowledgement is ambiguous, so poison the live handle and preserve
5378    /// the assigned epoch in a structured unknown-outcome error.
5379    ///
5380    /// Used by the DDL/maintenance commit paths, which do not pre-assign a
5381    /// commit timestamp: the receipt's `commit_ts` is allocated from the
5382    /// node's HLC clock at seal time.
5383    fn await_durable_commit(
5384        &self,
5385        txn_id: u64,
5386        commit_seq: u64,
5387        epoch: Epoch,
5388    ) -> Result<mongreldb_log::CommitReceipt> {
5389        match self
5390            .standalone_commit_log
5391            .seal_transaction(txn_id, epoch, commit_seq, None)
5392        {
5393            Ok(receipt) => {
5394                self.record_commit_ts(epoch, receipt.commit_ts);
5395                Ok(receipt)
5396            }
5397            Err(error) => {
5398                self.poisoned.store(true, Ordering::Relaxed);
5399                self.lifecycle.poison();
5400                Err(MongrelError::CommitOutcomeUnknown {
5401                    epoch: epoch.0,
5402                    message: error.to_string(),
5403                })
5404            }
5405        }
5406    }
5407
5408    /// [`Self::await_durable_commit`] for the transaction commit sequencer,
5409    /// which assigned `commit_ts` under the sequencer lock (S1B-004 step 5,
5410    /// spec §8.2). The receipt carries that exact timestamp, matching the
5411    /// durable `Op::CommitTimestamp` ledger record written at append.
5412    fn await_durable_commit_with_ts(
5413        &self,
5414        txn_id: u64,
5415        commit_seq: u64,
5416        epoch: Epoch,
5417        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5418    ) -> Result<mongreldb_log::CommitReceipt> {
5419        match self.standalone_commit_log.seal_transaction(
5420            txn_id,
5421            epoch,
5422            commit_seq,
5423            Some(commit_ts),
5424        ) {
5425            Ok(receipt) => {
5426                self.record_commit_ts(epoch, receipt.commit_ts);
5427                Ok(receipt)
5428            }
5429            Err(error) => {
5430                self.poisoned.store(true, Ordering::Relaxed);
5431                self.lifecycle.poison();
5432                Err(MongrelError::CommitOutcomeUnknown {
5433                    epoch: epoch.0,
5434                    message: error.to_string(),
5435                })
5436            }
5437        }
5438    }
5439
5440    /// Record one durable commit's receipt timestamp in the per-open ledger
5441    /// (bounded to the newest [`COMMIT_TS_LEDGER_CAP`] epochs). Called by the
5442    /// durability funnels once the commit log has issued the irrevocable
5443    /// receipt.
5444    fn record_commit_ts(&self, epoch: Epoch, commit_ts: mongreldb_types::hlc::HlcTimestamp) {
5445        let mut ledger = self.commit_ts_ledger.lock();
5446        ledger.insert(epoch.0, commit_ts);
5447        while ledger.len() > COMMIT_TS_LEDGER_CAP {
5448            ledger.pop_first();
5449        }
5450    }
5451
5452    /// The commit timestamp of a durable commit, by epoch — the literal
5453    /// write receipt behind the server's read-your-writes token (spec §8.2).
5454    ///
5455    /// Returns `Some` for commits sealed within this open (the exact receipt
5456    /// `HlcTimestamp`) and for commits recovered from the durable
5457    /// `Op::CommitTimestamp` WAL ledger at open; the latter reconstruct the
5458    /// physical component only, with `logical` and `node_tiebreaker` as 0 per
5459    /// the ledger byte format. Only the newest [`COMMIT_TS_LEDGER_CAP`]
5460    /// epochs are retained, and epochs sealed through `CommitLog::propose`
5461    /// (catalog-command proposals) are not recorded here within a live open;
5462    /// both miss shapes return `None`, and callers fall back to a fresh-begin
5463    /// HLC, which the single clock authority orders after every commit it has
5464    /// already issued.
5465    pub fn commit_ts_for_epoch(&self, epoch: Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp> {
5466        self.commit_ts_ledger.lock().get(&epoch.0).copied()
5467    }
5468
5469    /// Newest retained commit epoch whose HLC timestamp is not after `timestamp`.
5470    ///
5471    /// The mapping is bounded by [`COMMIT_TS_LEDGER_CAP`]. Callers needing an
5472    /// older historical point must treat `None` as unavailable, never guess an
5473    /// epoch.
5474    pub fn epoch_at_or_before_commit_ts(
5475        &self,
5476        timestamp: mongreldb_types::hlc::HlcTimestamp,
5477    ) -> Option<Epoch> {
5478        self.commit_ts_ledger
5479            .lock()
5480            .iter()
5481            .rev()
5482            .find_map(|(epoch, commit_ts)| (*commit_ts <= timestamp).then_some(Epoch(*epoch)))
5483    }
5484
5485    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
5486        self.poisoned.store(true, Ordering::Relaxed);
5487        self.lifecycle.poison();
5488        MongrelError::CommitOutcomeUnknown {
5489            epoch: epoch.0,
5490            message: error.to_string(),
5491        }
5492    }
5493
5494    /// Persist a complete validated RLS/masking catalog through the WAL.
5495    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
5496        self.set_security_catalog_as_with_epoch(security, None)
5497            .map(|_| ())
5498    }
5499
5500    /// Persist security policy changes on behalf of an explicit request principal.
5501    pub fn set_security_catalog_as(
5502        &self,
5503        security: crate::security::SecurityCatalog,
5504        principal: Option<&crate::auth::Principal>,
5505    ) -> Result<()> {
5506        self.set_security_catalog_as_with_epoch(security, principal)
5507            .map(|_| ())
5508    }
5509
5510    /// Persist security policy changes and return the exact publication epoch.
5511    pub fn set_security_catalog_as_with_epoch(
5512        &self,
5513        security: crate::security::SecurityCatalog,
5514        principal: Option<&crate::auth::Principal>,
5515    ) -> Result<Epoch> {
5516        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
5517    }
5518
5519    /// Persist security policy changes, entering the commit fence immediately
5520    /// before the first WAL record can become visible to recovery.
5521    pub fn set_security_catalog_as_with_epoch_controlled<F>(
5522        &self,
5523        security: crate::security::SecurityCatalog,
5524        principal: Option<&crate::auth::Principal>,
5525        mut before_commit: F,
5526    ) -> Result<Epoch>
5527    where
5528        F: FnMut() -> Result<()>,
5529    {
5530        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
5531    }
5532
5533    fn set_security_catalog_as_with_epoch_inner(
5534        &self,
5535        security: crate::security::SecurityCatalog,
5536        principal: Option<&crate::auth::Principal>,
5537        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
5538    ) -> Result<Epoch> {
5539        use crate::wal::DdlOp;
5540        use std::sync::atomic::Ordering;
5541
5542        // S1F-001: the mutation is a versioned catalog command; its required
5543        // permission is checked against the caller principal first (identical
5544        // to the legacy hardcoded Admin gate).
5545        let command = crate::catalog_cmds::CatalogCommand::SetSecurityCatalog {
5546            security: security.clone(),
5547        };
5548        self.require_for(
5549            principal,
5550            &crate::catalog_cmds::required_permission(&command),
5551        )?;
5552        if self.poisoned.load(Ordering::Relaxed) {
5553            return Err(MongrelError::Other(
5554                "database poisoned by fsync error".into(),
5555            ));
5556        }
5557        // S1A-004: admit the security-catalog replacement as one core
5558        // operation.
5559        let _operation = self.admit_operation()?;
5560        let _ddl = self.ddl_lock.lock();
5561        // DDL serializes first; write-path order after that is security gate ->
5562        // commit lock -> shared WAL.
5563        let _security_write = self.security_write()?;
5564        self.require_for(
5565            principal,
5566            &crate::catalog_cmds::required_permission(&command),
5567        )?;
5568        let mut next_catalog = self.catalog.read().clone();
5569        validate_security_catalog(&next_catalog, &security)?;
5570        let payload = DdlOp::encode_security(&security)?;
5571        let _commit = self.commit_lock.lock();
5572        let epoch = self.epoch.bump_assigned();
5573        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5574        let txn_id = self.alloc_txn_id()?;
5575        self.apply_catalog_command_to(&mut next_catalog, command)?;
5576        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
5577        let commit_seq = {
5578            let mut wal = self.shared_wal.lock();
5579            if let Some(before_commit) = before_commit {
5580                before_commit()?;
5581            }
5582            let append: Result<u64> = (|| {
5583                wal.append(
5584                    txn_id,
5585                    WAL_TABLE_ID,
5586                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
5587                        security_json: payload,
5588                    }),
5589                )?;
5590                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
5591                wal.append_commit(txn_id, epoch, &[])
5592            })();
5593            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5594        };
5595        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5596        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
5597        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
5598        Ok(epoch)
5599    }
5600
5601    pub fn require_for(
5602        &self,
5603        principal: Option<&crate::auth::Principal>,
5604        permission: &crate::auth::Permission,
5605    ) -> Result<()> {
5606        let Some(principal) = principal else {
5607            return self.require(permission);
5608        };
5609        let resolved;
5610        let principal = if principal.user_id != 0 {
5611            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5612                .ok_or(MongrelError::AuthRequired)?;
5613            &resolved
5614        } else {
5615            principal
5616        };
5617        #[cfg(test)]
5618        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5619        if principal.has_permission(permission) {
5620            Ok(())
5621        } else {
5622            Err(MongrelError::PermissionDenied {
5623                required: permission.clone(),
5624                principal: principal.username.clone(),
5625            })
5626        }
5627    }
5628
5629    /// Recheck the exact operation principal while the caller holds the
5630    /// security gate. This deliberately performs no refresh or nested gate
5631    /// acquisition.
5632    fn require_exact_principal_current(
5633        &self,
5634        principal: Option<&crate::auth::Principal>,
5635        permission: &crate::auth::Permission,
5636    ) -> Result<()> {
5637        let catalog = self.catalog.read();
5638        if !catalog.require_auth {
5639            return Ok(());
5640        }
5641        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
5642        let current = if supplied.user_id == 0 {
5643            supplied.clone()
5644        } else {
5645            Self::resolve_bound_principal_from_catalog(&catalog, supplied)
5646                .ok_or(MongrelError::AuthRequired)?
5647        };
5648        if current.has_permission(permission) {
5649            Ok(())
5650        } else {
5651            Err(MongrelError::PermissionDenied {
5652                required: permission.clone(),
5653                principal: current.username,
5654            })
5655        }
5656    }
5657
5658    pub(crate) fn with_exact_principal_current<T, F>(
5659        &self,
5660        principal: Option<&crate::auth::Principal>,
5661        permission: &crate::auth::Permission,
5662        operation: F,
5663    ) -> Result<T>
5664    where
5665        F: FnOnce() -> Result<T>,
5666    {
5667        let _security = self.security_coordinator.gate.read();
5668        self.require_exact_principal_current(principal, permission)?;
5669        operation()
5670    }
5671
5672    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
5673        self.principal.read().clone()
5674    }
5675
5676    #[cfg(test)]
5677    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
5678        *self.principal.write() = principal.clone();
5679        self.auth_state.set_principal(principal);
5680    }
5681
5682    pub fn require_columns_for(
5683        &self,
5684        table: &str,
5685        operation: crate::auth::ColumnOperation,
5686        column_ids: &[u16],
5687        principal: Option<&crate::auth::Principal>,
5688    ) -> Result<()> {
5689        if principal.is_none() && !self.auth_state.require_auth() {
5690            return Ok(());
5691        }
5692        let cached = self.principal.read().clone();
5693        let principal = principal.or(cached.as_ref());
5694        let Some(principal) = principal else {
5695            let permission = match operation {
5696                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5697                    table: table.to_string(),
5698                },
5699                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5700                    table: table.to_string(),
5701                },
5702                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5703                    table: table.to_string(),
5704                },
5705            };
5706            return self.require(&permission);
5707        };
5708        let catalog = self.catalog.read();
5709        let resolved;
5710        let principal = if principal.user_id != 0 {
5711            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
5712                .ok_or(MongrelError::AuthRequired)?;
5713            &resolved
5714        } else {
5715            principal
5716        };
5717        let schema = &catalog
5718            .live(table)
5719            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5720            .schema;
5721        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
5722    }
5723
5724    fn require_columns_for_principal(
5725        table: &str,
5726        schema: &Schema,
5727        operation: crate::auth::ColumnOperation,
5728        column_ids: &[u16],
5729        principal: &crate::auth::Principal,
5730    ) -> Result<()> {
5731        #[cfg(test)]
5732        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5733        match principal.column_access(table, operation) {
5734            crate::auth::ColumnAccess::All => Ok(()),
5735            crate::auth::ColumnAccess::Columns(allowed) => {
5736                let denied = column_ids.iter().find_map(|column_id| {
5737                    schema
5738                        .columns
5739                        .iter()
5740                        .find(|column| column.id == *column_id)
5741                        .filter(|column| !allowed.contains(&column.name))
5742                });
5743                if denied.is_none() {
5744                    Ok(())
5745                } else {
5746                    Err(MongrelError::PermissionDenied {
5747                        required: match operation {
5748                            crate::auth::ColumnOperation::Select => {
5749                                crate::auth::Permission::SelectColumns {
5750                                    table: table.to_string(),
5751                                    columns: denied
5752                                        .into_iter()
5753                                        .map(|column| column.name.clone())
5754                                        .collect(),
5755                                }
5756                            }
5757                            crate::auth::ColumnOperation::Insert => {
5758                                crate::auth::Permission::InsertColumns {
5759                                    table: table.to_string(),
5760                                    columns: denied
5761                                        .into_iter()
5762                                        .map(|column| column.name.clone())
5763                                        .collect(),
5764                                }
5765                            }
5766                            crate::auth::ColumnOperation::Update => {
5767                                crate::auth::Permission::UpdateColumns {
5768                                    table: table.to_string(),
5769                                    columns: denied
5770                                        .into_iter()
5771                                        .map(|column| column.name.clone())
5772                                        .collect(),
5773                                }
5774                            }
5775                        },
5776                        principal: principal.username.clone(),
5777                    })
5778                }
5779            }
5780            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5781                required: match operation {
5782                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5783                        table: table.to_string(),
5784                    },
5785                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5786                        table: table.to_string(),
5787                    },
5788                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5789                        table: table.to_string(),
5790                    },
5791                },
5792                principal: principal.username.clone(),
5793            }),
5794        }
5795    }
5796
5797    pub fn select_column_ids_for(
5798        &self,
5799        table: &str,
5800        principal: Option<&crate::auth::Principal>,
5801    ) -> Result<Vec<u16>> {
5802        let catalog = self.catalog.read();
5803        let columns = catalog
5804            .live(table)
5805            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5806            .schema
5807            .columns
5808            .iter()
5809            .map(|column| (column.id, column.name.clone()))
5810            .collect::<Vec<_>>();
5811        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
5812        drop(catalog);
5813        let Some(principal) = principal.as_ref() else {
5814            self.require(&crate::auth::Permission::Select {
5815                table: table.to_string(),
5816            })?;
5817            return Ok(columns.iter().map(|(id, _)| *id).collect());
5818        };
5819        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
5820            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
5821            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
5822                .iter()
5823                .filter(|(_, name)| allowed.contains(name))
5824                .map(|(id, _)| *id)
5825                .collect()),
5826            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5827                required: crate::auth::Permission::Select {
5828                    table: table.to_string(),
5829                },
5830                principal: principal.username.clone(),
5831            }),
5832        }
5833    }
5834
5835    pub fn secure_rows_for(
5836        &self,
5837        table: &str,
5838        rows: Vec<crate::memtable::Row>,
5839        principal: Option<&crate::auth::Principal>,
5840    ) -> Result<Vec<crate::memtable::Row>> {
5841        self.secure_rows_for_with_context(table, rows, principal, None)
5842    }
5843
5844    pub fn secure_rows_for_with_context(
5845        &self,
5846        table: &str,
5847        rows: Vec<crate::memtable::Row>,
5848        principal: Option<&crate::auth::Principal>,
5849        context: Option<&crate::query::AiExecutionContext>,
5850    ) -> Result<Vec<crate::memtable::Row>> {
5851        let (security, principal) = {
5852            let catalog = self.catalog.read();
5853            (
5854                catalog.security.clone(),
5855                self.principal_for_authorized_read(&catalog, principal, false)?,
5856            )
5857        };
5858        if !security.table_has_security(table) {
5859            return Ok(rows);
5860        }
5861        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5862        let mut output = Vec::new();
5863        for mut row in rows {
5864            if let Some(context) = context {
5865                context.consume(1)?;
5866            }
5867            if security.row_allowed(
5868                table,
5869                crate::security::PolicyCommand::Select,
5870                &row,
5871                principal,
5872                false,
5873            ) {
5874                security.apply_masks(table, &mut row, principal);
5875                output.push(row);
5876            }
5877        }
5878        Ok(output)
5879    }
5880
5881    /// Apply column masks to already RLS-authorized scored hits without a
5882    /// second row gather or policy evaluation.
5883    pub fn mask_search_hits_for(
5884        &self,
5885        table: &str,
5886        hits: &mut [crate::query::SearchHit],
5887        principal: Option<&crate::auth::Principal>,
5888    ) -> Result<()> {
5889        let (security, principal) = {
5890            let catalog = self.catalog.read();
5891            (
5892                catalog.security.clone(),
5893                self.principal_for_authorized_read(&catalog, principal, false)?,
5894            )
5895        };
5896        if !security.table_has_security(table) {
5897            return Ok(());
5898        }
5899        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5900        for hit in hits {
5901            security.apply_masks_to_cells(table, &mut hit.cells, principal);
5902        }
5903        Ok(())
5904    }
5905
5906    /// Apply masks to rows already admitted by candidate-aware RLS.
5907    pub fn mask_rows_for(
5908        &self,
5909        table: &str,
5910        rows: &mut [crate::memtable::Row],
5911        principal: Option<&crate::auth::Principal>,
5912    ) -> Result<()> {
5913        let (security, principal) = {
5914            let catalog = self.catalog.read();
5915            (
5916                catalog.security.clone(),
5917                self.principal_for_authorized_read(&catalog, principal, false)?,
5918            )
5919        };
5920        if !security.table_has_security(table) {
5921            return Ok(());
5922        }
5923        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5924        for row in rows {
5925            security.apply_masks(table, row, principal);
5926        }
5927        Ok(())
5928    }
5929
5930    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
5931    pub fn authorized_candidate_ids_for(
5932        &self,
5933        table: &str,
5934        principal: Option<&crate::auth::Principal>,
5935    ) -> Result<Option<std::collections::HashSet<RowId>>> {
5936        Ok(self
5937            .authorized_read_snapshot(table, principal)?
5938            .allowed_row_ids)
5939    }
5940
5941    fn allowed_row_ids_locked(
5942        &self,
5943        table_name: &str,
5944        table: &Table,
5945        table_snapshot: Snapshot,
5946        security_state: (&crate::security::SecurityCatalog, u64),
5947        principal: Option<&crate::auth::Principal>,
5948        context: Option<&crate::query::AiExecutionContext>,
5949    ) -> Result<Option<Arc<HashSet<RowId>>>> {
5950        let (security, security_version) = security_state;
5951        if !security.rls_enabled(table_name) {
5952            return Ok(None);
5953        }
5954        let authorization_started = std::time::Instant::now();
5955        let principal = principal.ok_or(MongrelError::AuthRequired)?;
5956        let mut roles = principal.roles.clone();
5957        roles.sort_unstable();
5958        let principal_key = format!(
5959            "{}:{}:{}:{}:{roles:?}",
5960            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
5961        );
5962        let cache_key = (
5963            table_name.to_string(),
5964            table.data_generation(),
5965            security_version,
5966            principal_key,
5967        );
5968        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
5969            crate::trace::QueryTrace::record(|trace| {
5970                trace.rls_cache_hit = true;
5971                trace.authorization_nanos = trace
5972                    .authorization_nanos
5973                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5974            });
5975            return Ok(Some(allowed));
5976        }
5977        if let Some(context) = context {
5978            context.checkpoint()?;
5979        }
5980        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
5981        let started = std::time::Instant::now();
5982        let rows = table.visible_rows(table_snapshot)?;
5983        let rows_evaluated = rows.len() as u64;
5984        let mut allowed = HashSet::new();
5985        for chunk in rows.chunks(256) {
5986            if let Some(context) = context {
5987                context.consume(chunk.len())?;
5988            }
5989            allowed.extend(chunk.iter().filter_map(|row| {
5990                security
5991                    .row_allowed(
5992                        table_name,
5993                        crate::security::PolicyCommand::Select,
5994                        row,
5995                        principal,
5996                        false,
5997                    )
5998                    .then_some(row.row_id)
5999            }));
6000        }
6001        let allowed = Arc::new(allowed);
6002        let mut cache = self.rls_cache.lock();
6003        cache.build_nanos = cache
6004            .build_nanos
6005            .saturating_add(started.elapsed().as_nanos() as u64);
6006        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
6007        cache.insert(cache_key, Arc::clone(&allowed));
6008        crate::trace::QueryTrace::record(|trace| {
6009            trace.rls_rows_evaluated = trace
6010                .rls_rows_evaluated
6011                .saturating_add(rows_evaluated as usize);
6012            trace.authorization_nanos = trace
6013                .authorization_nanos
6014                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
6015        });
6016        Ok(Some(allowed))
6017    }
6018
6019    fn principal_for_authorized_read(
6020        &self,
6021        catalog: &Catalog,
6022        principal: Option<&crate::auth::Principal>,
6023        catalog_bound: bool,
6024    ) -> Result<Option<crate::auth::Principal>> {
6025        let principal = principal.cloned().or_else(|| self.principal.read().clone());
6026        let Some(principal) = principal else {
6027            return Ok(None);
6028        };
6029        if catalog_bound || principal.user_id != 0 {
6030            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
6031                .map(Some)
6032                .ok_or(MongrelError::AuthRequired);
6033        }
6034        Ok(Some(principal))
6035    }
6036
6037    /// Run authorization, candidate generation, ranking, and materialization
6038    /// while holding one table generation. Security changes cause a bounded
6039    /// retry before any result is published.
6040    pub fn with_authorized_read<T, F>(
6041        &self,
6042        table_name: &str,
6043        principal: Option<&crate::auth::Principal>,
6044        catalog_bound: bool,
6045        read: F,
6046    ) -> Result<T>
6047    where
6048        F: FnMut(
6049            &mut Table,
6050            Snapshot,
6051            Option<&HashSet<RowId>>,
6052            Option<&crate::auth::Principal>,
6053        ) -> Result<T>,
6054    {
6055        self.with_authorized_read_context(
6056            table_name,
6057            principal,
6058            catalog_bound,
6059            None,
6060            None,
6061            None,
6062            read,
6063        )
6064    }
6065
6066    #[allow(clippy::too_many_arguments)]
6067    pub fn with_authorized_read_context<T, F>(
6068        &self,
6069        table_name: &str,
6070        principal: Option<&crate::auth::Principal>,
6071        catalog_bound: bool,
6072        authorization: Option<&ReadAuthorization>,
6073        context: Option<&crate::query::AiExecutionContext>,
6074        snapshot_override: Option<Snapshot>,
6075        read: F,
6076    ) -> Result<T>
6077    where
6078        F: FnMut(
6079            &mut Table,
6080            Snapshot,
6081            Option<&HashSet<RowId>>,
6082            Option<&crate::auth::Principal>,
6083        ) -> Result<T>,
6084    {
6085        self.with_authorized_read_context_stamped(
6086            table_name,
6087            principal,
6088            catalog_bound,
6089            authorization,
6090            context,
6091            snapshot_override,
6092            read,
6093        )
6094        .map(|(result, _)| result)
6095    }
6096
6097    #[allow(clippy::too_many_arguments)]
6098    pub fn with_authorized_read_context_stamped<T, F>(
6099        &self,
6100        table_name: &str,
6101        principal: Option<&crate::auth::Principal>,
6102        catalog_bound: bool,
6103        authorization: Option<&ReadAuthorization>,
6104        context: Option<&crate::query::AiExecutionContext>,
6105        snapshot_override: Option<Snapshot>,
6106        mut read: F,
6107    ) -> Result<(T, AuthorizedReadStamp)>
6108    where
6109        F: FnMut(
6110            &mut Table,
6111            Snapshot,
6112            Option<&HashSet<RowId>>,
6113            Option<&crate::auth::Principal>,
6114        ) -> Result<T>,
6115    {
6116        if principal.is_none() && self.principal.read().is_some() {
6117            self.refresh_principal()?;
6118        }
6119        const RETRIES: usize = 3;
6120        let handle = self.table(table_name)?;
6121        for attempt in 0..RETRIES {
6122            crate::trace::QueryTrace::record(|trace| {
6123                trace.authorization_retries = attempt;
6124            });
6125            let (security, security_version, effective_principal) = {
6126                let catalog = self.catalog.read();
6127                (
6128                    catalog.security.clone(),
6129                    catalog.security_version,
6130                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6131                )
6132            };
6133            if let Some(authorization) = authorization {
6134                for permission in &authorization.permissions {
6135                    self.require_for(effective_principal.as_ref(), permission)?;
6136                }
6137                self.require_columns_for(
6138                    table_name,
6139                    authorization.operation,
6140                    &authorization.columns,
6141                    effective_principal.as_ref(),
6142                )?;
6143            }
6144            let result = {
6145                let mut table = lock_table_with_context(&handle, context)?;
6146                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
6147                let allowed = self.allowed_row_ids_locked(
6148                    table_name,
6149                    &table,
6150                    snapshot,
6151                    (&security, security_version),
6152                    effective_principal.as_ref(),
6153                    context,
6154                )?;
6155                let stamp = AuthorizedReadStamp {
6156                    table_id: table.table_id(),
6157                    schema_id: table.schema().schema_id,
6158                    data_generation: table.data_generation(),
6159                    security_version,
6160                    snapshot,
6161                };
6162                let result = read(
6163                    &mut table,
6164                    snapshot,
6165                    allowed.as_deref(),
6166                    effective_principal.as_ref(),
6167                )?;
6168                (result, stamp)
6169            };
6170            if let Some(context) = context {
6171                context.checkpoint()?;
6172            }
6173            if self.catalog.read().security_version == security_version {
6174                return Ok(result);
6175            }
6176            if attempt + 1 == RETRIES {
6177                return Err(MongrelError::Conflict(
6178                    "security policy changed during scored read".into(),
6179                ));
6180            }
6181        }
6182        Err(MongrelError::Conflict(
6183            "authorization retry loop exhausted".into(),
6184        ))
6185    }
6186
6187    fn with_authorized_aggregate_table<T, F>(
6188        &self,
6189        table_name: &str,
6190        columns: &[u16],
6191        principal: Option<&crate::auth::Principal>,
6192        catalog_bound: bool,
6193        allow_table_security: bool,
6194        mut aggregate: F,
6195    ) -> Result<T>
6196    where
6197        F: FnMut(
6198            &mut Table,
6199            Option<&crate::security::CandidateAuthorization<'_>>,
6200            Option<&crate::auth::Principal>,
6201            u64,
6202        ) -> Result<T>,
6203    {
6204        if principal.is_none() && self.principal.read().is_some() {
6205            self.refresh_principal()?;
6206        }
6207        const RETRIES: usize = 3;
6208        let handle = self.table(table_name)?;
6209        for attempt in 0..RETRIES {
6210            let (security, security_version, effective_principal) = {
6211                let catalog = self.catalog.read();
6212                (
6213                    catalog.security.clone(),
6214                    catalog.security_version,
6215                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6216                )
6217            };
6218            self.require_columns_for(
6219                table_name,
6220                crate::auth::ColumnOperation::Select,
6221                columns,
6222                effective_principal.as_ref(),
6223            )?;
6224            if !allow_table_security && security.table_has_security(table_name) {
6225                return Err(MongrelError::InvalidArgument(
6226                    "incremental aggregate is unsupported while RLS or column masks are active"
6227                        .into(),
6228                ));
6229            }
6230            let result = {
6231                let mut table = handle.lock();
6232                let authorization = if security.rls_enabled(table_name) {
6233                    Some(crate::security::CandidateAuthorization {
6234                        table: table_name,
6235                        security: &security,
6236                        principal: effective_principal
6237                            .as_ref()
6238                            .ok_or(MongrelError::AuthRequired)?,
6239                    })
6240                } else {
6241                    None
6242                };
6243                aggregate(
6244                    &mut table,
6245                    authorization.as_ref(),
6246                    effective_principal.as_ref(),
6247                    security_version,
6248                )?
6249            };
6250            if self.catalog.read().security_version == security_version {
6251                return Ok(result);
6252            }
6253            if attempt + 1 == RETRIES {
6254                return Err(MongrelError::Conflict(
6255                    "security policy changed during aggregate read".into(),
6256                ));
6257            }
6258        }
6259        Err(MongrelError::Conflict(
6260            "aggregate authorization retry loop exhausted".into(),
6261        ))
6262    }
6263
6264    /// Scored-read authorization that evaluates RLS only for approximate
6265    /// candidates. This avoids a full-table policy scan on cache misses while
6266    /// preserving one table generation and security-version retry.
6267    pub fn with_authorized_scored_read_context<T, F>(
6268        &self,
6269        table_name: &str,
6270        principal: Option<&crate::auth::Principal>,
6271        catalog_bound: bool,
6272        authorization: Option<&ReadAuthorization>,
6273        context: Option<&crate::query::AiExecutionContext>,
6274        mut read: F,
6275    ) -> Result<T>
6276    where
6277        F: FnMut(
6278            &mut Table,
6279            Snapshot,
6280            Option<&crate::security::CandidateAuthorization<'_>>,
6281            Option<&crate::auth::Principal>,
6282        ) -> Result<T>,
6283    {
6284        self.with_authorized_scored_read_context_at(
6285            table_name,
6286            principal,
6287            catalog_bound,
6288            authorization,
6289            context,
6290            None,
6291            |table, snapshot, authorization, principal| {
6292                let mut table = table.clone();
6293                read(&mut table, snapshot, authorization, principal)
6294            },
6295        )
6296    }
6297
6298    #[allow(clippy::too_many_arguments)]
6299    pub fn with_authorized_scored_read_context_at<T, F>(
6300        &self,
6301        table_name: &str,
6302        principal: Option<&crate::auth::Principal>,
6303        catalog_bound: bool,
6304        authorization: Option<&ReadAuthorization>,
6305        context: Option<&crate::query::AiExecutionContext>,
6306        snapshot_override: Option<Snapshot>,
6307        read: F,
6308    ) -> Result<T>
6309    where
6310        F: FnMut(
6311            &Table,
6312            Snapshot,
6313            Option<&crate::security::CandidateAuthorization<'_>>,
6314            Option<&crate::auth::Principal>,
6315        ) -> Result<T>,
6316    {
6317        self.with_authorized_scored_read_context_at_stamped(
6318            table_name,
6319            principal,
6320            catalog_bound,
6321            authorization,
6322            context,
6323            snapshot_override,
6324            read,
6325        )
6326        .map(|(result, _)| result)
6327    }
6328
6329    #[allow(clippy::too_many_arguments)]
6330    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
6331        &self,
6332        table_name: &str,
6333        principal: Option<&crate::auth::Principal>,
6334        catalog_bound: bool,
6335        authorization: Option<&ReadAuthorization>,
6336        context: Option<&crate::query::AiExecutionContext>,
6337        snapshot_override: Option<Snapshot>,
6338        mut read: F,
6339    ) -> Result<(T, AuthorizedReadStamp)>
6340    where
6341        F: FnMut(
6342            &Table,
6343            Snapshot,
6344            Option<&crate::security::CandidateAuthorization<'_>>,
6345            Option<&crate::auth::Principal>,
6346        ) -> Result<T>,
6347    {
6348        if principal.is_none() && self.principal.read().is_some() {
6349            self.refresh_principal()?;
6350        }
6351        const RETRIES: usize = 3;
6352        let handle = self.table(table_name)?;
6353        for attempt in 0..RETRIES {
6354            if let Some(context) = context {
6355                context.checkpoint()?;
6356            }
6357            crate::trace::QueryTrace::record(|trace| {
6358                trace.authorization_retries = attempt;
6359            });
6360            let (security, security_version, effective_principal) = {
6361                let catalog = self.catalog.read();
6362                (
6363                    catalog.security.clone(),
6364                    catalog.security_version,
6365                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6366                )
6367            };
6368            if let Some(authorization) = authorization {
6369                for permission in &authorization.permissions {
6370                    self.require_for(effective_principal.as_ref(), permission)?;
6371                }
6372                self.require_columns_for(
6373                    table_name,
6374                    authorization.operation,
6375                    &authorization.columns,
6376                    effective_principal.as_ref(),
6377                )?;
6378            }
6379            let result = {
6380                let (table, snapshot, _snapshot_guard, _run_pins) =
6381                    self.scored_read_generation(&handle, context, snapshot_override)?;
6382                let candidate_authorization = if security.rls_enabled(table_name) {
6383                    Some(crate::security::CandidateAuthorization {
6384                        table: table_name,
6385                        security: &security,
6386                        principal: effective_principal
6387                            .as_ref()
6388                            .ok_or(MongrelError::AuthRequired)?,
6389                    })
6390                } else {
6391                    None
6392                };
6393                let stamp = AuthorizedReadStamp {
6394                    table_id: table.table_id(),
6395                    schema_id: table.schema().schema_id,
6396                    data_generation: table.data_generation(),
6397                    security_version,
6398                    snapshot,
6399                };
6400                let result = read(
6401                    table.as_ref(),
6402                    snapshot,
6403                    candidate_authorization.as_ref(),
6404                    effective_principal.as_ref(),
6405                )?;
6406                (result, stamp)
6407            };
6408            if let Some(context) = context {
6409                context.checkpoint()?;
6410            }
6411            if self.catalog.read().security_version == security_version {
6412                return Ok(result);
6413            }
6414            if attempt + 1 == RETRIES {
6415                return Err(MongrelError::Conflict(
6416                    "security policy changed during scored read".into(),
6417                ));
6418            }
6419        }
6420        Err(MongrelError::Conflict(
6421            "scored-read authorization retry loop exhausted".into(),
6422        ))
6423    }
6424
6425    fn scored_read_generation(
6426        &self,
6427        handle: &TableHandle,
6428        context: Option<&crate::query::AiExecutionContext>,
6429        snapshot_override: Option<Snapshot>,
6430    ) -> Result<(
6431        Arc<TableReadGeneration>,
6432        Snapshot,
6433        crate::retention::OwnedSnapshotGuard,
6434        RunPins,
6435    )> {
6436        let mut table = if let Some(context) = context {
6437            loop {
6438                context.checkpoint()?;
6439                let wait = context
6440                    .remaining_duration()
6441                    .unwrap_or(std::time::Duration::from_millis(5))
6442                    .min(std::time::Duration::from_millis(5));
6443                if let Some(table) = handle.try_lock_for(wait) {
6444                    break table;
6445                }
6446            }
6447        } else {
6448            handle.lock()
6449        };
6450        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
6451            self.snapshot_at_owned(snapshot.epoch)?
6452        } else {
6453            let snapshot = table.snapshot();
6454            let guard = self.snapshots.register_owned(snapshot.epoch);
6455            (snapshot, guard)
6456        };
6457        let table_id = table.table_id();
6458        let run_keys: Vec<_> = table
6459            .active_run_ids()
6460            .map(|run_id| (table_id, run_id))
6461            .collect();
6462        let generation = handle
6463            .generation_metrics
6464            .activate(table.clone_read_generation()?);
6465        let run_pins = self.pin_runs(&run_keys);
6466        Ok((generation, snapshot, snapshot_guard, run_pins))
6467    }
6468
6469    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
6470        let mut pins = self.backup_pins.lock();
6471        for run in runs {
6472            *pins.entry(*run).or_insert(0) += 1;
6473        }
6474        drop(pins);
6475        RunPins {
6476            pins: Arc::clone(&self.backup_pins),
6477            runs: runs.to_vec(),
6478        }
6479    }
6480
6481    /// Execute a native conjunctive read with the database principal's row
6482    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
6483    /// policy-unaware; language bindings must use this boundary for reads.
6484    pub fn query_for_current_principal(
6485        &self,
6486        table_name: &str,
6487        query: &crate::query::Query,
6488        projection: Option<&[u16]>,
6489    ) -> Result<Vec<crate::memtable::Row>> {
6490        let condition_columns = crate::query::condition_columns(&query.conditions);
6491        let catalog_bound = self
6492            .principal
6493            .read()
6494            .as_ref()
6495            .is_some_and(|principal| principal.user_id != 0);
6496        self.with_authorized_read(
6497            table_name,
6498            None,
6499            catalog_bound,
6500            |table, snapshot, allowed, principal| {
6501                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6502                self.require_columns_for(
6503                    table_name,
6504                    crate::auth::ColumnOperation::Select,
6505                    &condition_columns,
6506                    principal,
6507                )?;
6508                if let Some(projection) = projection {
6509                    self.require_columns_for(
6510                        table_name,
6511                        crate::auth::ColumnOperation::Select,
6512                        projection,
6513                        principal,
6514                    )?;
6515                }
6516                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
6517                let projection =
6518                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6519                for row in &mut rows {
6520                    row.columns.retain(|column, _| {
6521                        allowed_columns.contains(column)
6522                            && projection
6523                                .as_ref()
6524                                .is_none_or(|projection| projection.contains(column))
6525                    });
6526                }
6527                self.secure_rows_for(table_name, rows, principal)
6528            },
6529        )
6530    }
6531
6532    /// Execute a secured native read with cooperative cancellation across
6533    /// authorization, candidate generation, materialization, masking, and
6534    /// projection.
6535    pub fn query_for_current_principal_controlled(
6536        &self,
6537        table_name: &str,
6538        query: &crate::query::Query,
6539        projection: Option<&[u16]>,
6540        control: &crate::ExecutionControl,
6541    ) -> Result<Vec<crate::memtable::Row>> {
6542        let catalog_bound = self
6543            .principal
6544            .read()
6545            .as_ref()
6546            .is_some_and(|principal| principal.user_id != 0);
6547        self.query_for_principal_controlled(
6548            table_name,
6549            query,
6550            projection,
6551            None,
6552            catalog_bound,
6553            control,
6554        )
6555    }
6556
6557    /// Execute a secured native read as an explicitly validated principal.
6558    ///
6559    /// Protocol adapters use this after resolving a session-bound identity.
6560    pub fn query_as_principal_controlled(
6561        &self,
6562        table_name: &str,
6563        query: &crate::query::Query,
6564        projection: Option<&[u16]>,
6565        principal: Option<&crate::auth::Principal>,
6566        control: &crate::ExecutionControl,
6567    ) -> Result<Vec<crate::memtable::Row>> {
6568        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6569        self.query_for_principal_controlled(
6570            table_name,
6571            query,
6572            projection,
6573            principal,
6574            catalog_bound,
6575            control,
6576        )
6577    }
6578
6579    fn query_for_principal_controlled(
6580        &self,
6581        table_name: &str,
6582        query: &crate::query::Query,
6583        projection: Option<&[u16]>,
6584        principal: Option<&crate::auth::Principal>,
6585        catalog_bound: bool,
6586        control: &crate::ExecutionControl,
6587    ) -> Result<Vec<crate::memtable::Row>> {
6588        control.checkpoint()?;
6589        let context = crate::query::AiExecutionContext::with_control(
6590            control.clone(),
6591            usize::MAX,
6592            crate::query::MAX_FUSED_CANDIDATES,
6593        );
6594        let condition_columns = crate::query::condition_columns(&query.conditions);
6595        self.with_authorized_read_context(
6596            table_name,
6597            principal,
6598            catalog_bound,
6599            None,
6600            Some(&context),
6601            None,
6602            |table, snapshot, allowed, principal| {
6603                control.checkpoint()?;
6604                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6605                self.require_columns_for(
6606                    table_name,
6607                    crate::auth::ColumnOperation::Select,
6608                    &condition_columns,
6609                    principal,
6610                )?;
6611                if let Some(projection) = projection {
6612                    self.require_columns_for(
6613                        table_name,
6614                        crate::auth::ColumnOperation::Select,
6615                        projection,
6616                        principal,
6617                    )?;
6618                }
6619                let rows =
6620                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
6621                let projection =
6622                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6623                let mut projected = Vec::with_capacity(rows.len());
6624                for (index, mut row) in rows.into_iter().enumerate() {
6625                    if index & 255 == 0 {
6626                        control.checkpoint()?;
6627                    }
6628                    row.columns.retain(|column, _| {
6629                        allowed_columns.contains(column)
6630                            && projection
6631                                .as_ref()
6632                                .is_none_or(|projection| projection.contains(column))
6633                    });
6634                    projected.push(row);
6635                }
6636                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
6637            },
6638        )
6639    }
6640
6641    /// Reservoir aggregate with column grants, RLS, masks, and security-version
6642    /// retry applied at the database boundary.
6643    pub fn approx_aggregate_for_current_principal(
6644        &self,
6645        table_name: &str,
6646        conditions: &[crate::query::Condition],
6647        column: Option<u16>,
6648        agg: crate::engine::ApproxAgg,
6649        z: f64,
6650    ) -> Result<Option<crate::engine::ApproxResult>> {
6651        if !z.is_finite() || z <= 0.0 {
6652            return Err(MongrelError::InvalidArgument(
6653                "z must be finite and > 0".into(),
6654            ));
6655        }
6656        let mut columns = crate::query::condition_columns(conditions);
6657        columns.extend(column);
6658        columns.sort_unstable();
6659        columns.dedup();
6660        self.with_authorized_aggregate_table(
6661            table_name,
6662            &columns,
6663            None,
6664            true,
6665            true,
6666            |table, authorization, _, _| {
6667                table.approx_aggregate_with_candidate_authorization(
6668                    conditions,
6669                    column,
6670                    agg,
6671                    z,
6672                    authorization,
6673                )
6674            },
6675        )
6676    }
6677
6678    /// Incremental aggregate over an append-only table. Active RLS or masks are
6679    /// rejected because the table-global delta cache cannot safely represent a
6680    /// secured row universe.
6681    pub fn incremental_aggregate_for_current_principal(
6682        &self,
6683        table_name: &str,
6684        conditions: &[crate::query::Condition],
6685        column: Option<u16>,
6686        agg: crate::engine::NativeAgg,
6687    ) -> Result<crate::engine::IncrementalAggResult> {
6688        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
6689    }
6690
6691    /// Incremental aggregate using an explicit request principal. A
6692    /// catalog-bound principal is re-resolved on every retry so live grants,
6693    /// revocations, RLS, and masks cannot reuse a stale cache entry.
6694    pub fn incremental_aggregate_for_principal(
6695        &self,
6696        table_name: &str,
6697        conditions: &[crate::query::Condition],
6698        column: Option<u16>,
6699        agg: crate::engine::NativeAgg,
6700        principal: Option<&crate::auth::Principal>,
6701        catalog_bound: bool,
6702    ) -> Result<crate::engine::IncrementalAggResult> {
6703        let mut columns = crate::query::condition_columns(conditions);
6704        columns.extend(column);
6705        columns.sort_unstable();
6706        columns.dedup();
6707        self.with_authorized_aggregate_table(
6708            table_name,
6709            &columns,
6710            principal,
6711            catalog_bound,
6712            false,
6713            |table, _, principal, security_version| {
6714                let cache_key = incremental_aggregate_cache_key(
6715                    table_name,
6716                    conditions,
6717                    column,
6718                    agg,
6719                    principal,
6720                    security_version,
6721                );
6722                table.aggregate_incremental(cache_key, conditions, column, agg)
6723            },
6724        )
6725    }
6726
6727    /// Read one row with the database principal's row policy, column grants,
6728    /// and masks applied.
6729    pub fn get_for_current_principal(
6730        &self,
6731        table_name: &str,
6732        row_id: RowId,
6733    ) -> Result<Option<crate::memtable::Row>> {
6734        self.with_authorized_read(
6735            table_name,
6736            None,
6737            true,
6738            |table, snapshot, allowed, principal| {
6739                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6740                let Some(row) = table.get(row_id, snapshot) else {
6741                    return Ok(None);
6742                };
6743                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
6744                    return Ok(None);
6745                }
6746                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6747                if let Some(row) = rows.first_mut() {
6748                    row.columns
6749                        .retain(|column, _| allowed_columns.contains(column));
6750                }
6751                Ok(rows.pop())
6752            },
6753        )
6754    }
6755
6756    /// Run exact ANN reranking over only rows authorized for this database
6757    /// handle. The embedding column still requires normal column access.
6758    pub fn ann_rerank_for_current_principal(
6759        &self,
6760        table_name: &str,
6761        request: &crate::query::AnnRerankRequest,
6762    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6763        self.with_authorized_scored_read_context_at(
6764            table_name,
6765            None,
6766            true,
6767            Some(&ReadAuthorization {
6768                operation: crate::auth::ColumnOperation::Select,
6769                columns: vec![request.column_id],
6770                permissions: Vec::new(),
6771            }),
6772            None,
6773            None,
6774            |table, snapshot, authorization, principal| {
6775                self.require_columns_for(
6776                    table_name,
6777                    crate::auth::ColumnOperation::Select,
6778                    &[request.column_id],
6779                    principal,
6780                )?;
6781                table.ann_rerank_at_with_candidate_authorization_on_generation(
6782                    request,
6783                    snapshot,
6784                    authorization,
6785                    None,
6786                )
6787            },
6788        )
6789    }
6790
6791    /// Run a hybrid scored search over only rows authorized for this database
6792    /// handle. Applies retriever column grants, RLS, and masks to the returned
6793    /// hits.
6794    pub fn search_for_current_principal(
6795        &self,
6796        table_name: &str,
6797        request: &crate::query::SearchRequest,
6798    ) -> Result<Vec<crate::query::SearchHit>> {
6799        let principal = self.principal_snapshot();
6800        self.search_for_principal_with_context(table_name, request, principal.as_ref(), None)
6801    }
6802
6803    /// Run a hybrid scored search as an explicitly validated principal.
6804    ///
6805    /// Protocol adapters use this after validating their session-bound
6806    /// identity. RLS, column grants, masks, cancellation, deadlines, and work
6807    /// limits remain enforced inside the storage authorization boundary.
6808    pub fn search_for_principal_with_context(
6809        &self,
6810        table_name: &str,
6811        request: &crate::query::SearchRequest,
6812        principal: Option<&crate::auth::Principal>,
6813        context: Option<&crate::query::AiExecutionContext>,
6814    ) -> Result<Vec<crate::query::SearchHit>> {
6815        let mut columns = crate::query::condition_columns(&request.must);
6816        for named in &request.retrievers {
6817            columns.push(named.retriever.column_id());
6818        }
6819        if let Some(crate::query::Rerank::ExactVector {
6820            embedding_column, ..
6821        }) = &request.rerank
6822        {
6823            columns.push(*embedding_column);
6824        }
6825        if let Some(proj) = &request.projection {
6826            columns.extend(proj);
6827        }
6828        columns.sort_unstable();
6829        columns.dedup();
6830        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6831        self.with_authorized_scored_read_context_at(
6832            table_name,
6833            principal,
6834            catalog_bound,
6835            Some(&ReadAuthorization {
6836                operation: crate::auth::ColumnOperation::Select,
6837                columns: columns.clone(),
6838                permissions: Vec::new(),
6839            }),
6840            context,
6841            None,
6842            |table, snapshot, authorization, principal| {
6843                self.require_columns_for(
6844                    table_name,
6845                    crate::auth::ColumnOperation::Select,
6846                    &columns,
6847                    principal,
6848                )?;
6849                let hits = table.search_at_with_candidate_authorization_on_generation(
6850                    request,
6851                    snapshot,
6852                    authorization,
6853                    context,
6854                )?;
6855                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6856                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
6857                for mut hit in hits {
6858                    let row = crate::memtable::Row {
6859                        row_id: hit.row_id,
6860                        committed_epoch: crate::Epoch(0),
6861                        columns: hit
6862                            .cells
6863                            .iter()
6864                            .cloned()
6865                            .collect::<std::collections::HashMap<u16, crate::Value>>(),
6866                        deleted: false,
6867                        commit_ts: None,
6868                    };
6869                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6870                    if let Some(mut row) = rows.pop() {
6871                        row.columns
6872                            .retain(|column, _| allowed_columns.contains(column));
6873                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
6874                        hit.cells.sort_by_key(|(column, _)| *column);
6875                        secured.push(hit);
6876                    }
6877                }
6878                Ok(secured)
6879            },
6880        )
6881    }
6882
6883    /// Embed `text` with the active semantic identity for `embedding_column` and
6884    /// run ANN retrieval, returning hits plus query provenance (P0.7).
6885    pub fn retrieve_text(
6886        &self,
6887        table: &str,
6888        embedding_column: u16,
6889        text: &str,
6890        search_options: crate::embedding::TextSearchOptions,
6891    ) -> Result<crate::embedding::TextRetrieveResult> {
6892        let principal = self.principal_snapshot();
6893        self.retrieve_text_for_principal(
6894            table,
6895            embedding_column,
6896            text,
6897            search_options,
6898            principal.as_ref(),
6899        )
6900    }
6901
6902    pub fn retrieve_text_for_principal(
6903        &self,
6904        table: &str,
6905        embedding_column: u16,
6906        text: &str,
6907        search_options: crate::embedding::TextSearchOptions,
6908        principal: Option<&crate::auth::Principal>,
6909    ) -> Result<crate::embedding::TextRetrieveResult> {
6910        if search_options.k == 0 {
6911            return Err(MongrelError::InvalidArgument(
6912                "retrieve_text k must be greater than zero".into(),
6913            ));
6914        }
6915        let catalog_bound = principal.is_some_and(|p| p.user_id != 0);
6916        let (semantic_identity, registry_generation, expected_dim) = {
6917            let handle = self.table(table)?;
6918            let mut g = handle.lock();
6919            g.ensure_indexes_complete()?;
6920            let schema = g.schema().clone();
6921            let column = schema
6922                .columns
6923                .iter()
6924                .find(|c| c.id == embedding_column)
6925                .ok_or_else(|| {
6926                    MongrelError::ColumnNotFound(format!(
6927                        "embedding column {embedding_column} not found on table {table}"
6928                    ))
6929                })?;
6930            let dim = match column.ty {
6931                crate::schema::TypeId::Embedding { dim } => dim,
6932                _ => {
6933                    return Err(MongrelError::InvalidArgument(format!(
6934                        "column {embedding_column} is not an embedding column"
6935                    )))
6936                }
6937            };
6938            if let Some(identity) = g
6939                .ann_index(embedding_column)
6940                .and_then(|a| a.semantic_identity().cloned())
6941            {
6942                let (generation, provider) = self
6943                    .embedding_providers
6944                    .resolve_by_semantic_identity(&identity)
6945                    .map_err(embedding_error)?;
6946                if provider.dimension() != dim {
6947                    return Err(embedding_error(
6948                        crate::embedding::EmbeddingError::DimensionMismatch {
6949                            expected: dim,
6950                            got: provider.dimension(),
6951                        },
6952                    ));
6953                }
6954                (identity, generation, dim)
6955            } else {
6956                let source = column.embedding_source.clone().ok_or_else(|| {
6957                    embedding_error(crate::embedding::EmbeddingError::NoActiveAnnIdentity(
6958                        embedding_column,
6959                    ))
6960                })?;
6961                let (generation, provider) = self
6962                    .embedding_providers
6963                    .resolve_with_generation(&source)
6964                    .map_err(embedding_error)?;
6965                let identity = provider.semantic_identity();
6966                if identity.dimension != dim {
6967                    return Err(embedding_error(
6968                        crate::embedding::EmbeddingError::DimensionMismatch {
6969                            expected: dim,
6970                            got: identity.dimension,
6971                        },
6972                    ));
6973                }
6974                (identity, generation, dim)
6975            }
6976        };
6977        let (_g, provider) = self
6978            .embedding_providers
6979            .resolve_by_semantic_identity(&semantic_identity)
6980            .map_err(embedding_error)?;
6981        let control = crate::ExecutionControl::new(None);
6982        let response = provider
6983            .embed(crate::embedding::EmbeddingRequest {
6984                texts: &[text],
6985                control: &control,
6986                trace_id: "retrieve-text",
6987            })
6988            .map_err(embedding_error)?;
6989        crate::embedding::validate_response(
6990            provider.as_ref(),
6991            1,
6992            expected_dim,
6993            crate::embedding::EmbeddingLimits::default(),
6994            &response.vectors,
6995        )
6996        .map_err(embedding_error)?;
6997        let query = response.vectors.into_iter().next().ok_or_else(|| {
6998            MongrelError::Other("embedding provider returned no query vector".into())
6999        })?;
7000        if provider.semantic_identity() != semantic_identity {
7001            return Err(embedding_error(
7002                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7003                    column_id: embedding_column,
7004                    expected: semantic_identity.fingerprint_sha256(),
7005                    got: provider.semantic_identity().fingerprint_sha256(),
7006                },
7007            ));
7008        }
7009        let retriever = crate::query::Retriever::Ann {
7010            column_id: embedding_column,
7011            query,
7012            k: search_options.k,
7013        };
7014        let hits = self.with_authorized_scored_read_context_at(
7015            table,
7016            principal,
7017            catalog_bound,
7018            Some(&ReadAuthorization {
7019                operation: crate::auth::ColumnOperation::Select,
7020                columns: vec![embedding_column],
7021                permissions: Vec::new(),
7022            }),
7023            None,
7024            None,
7025            |table_guard, snapshot, authorization, principal| {
7026                self.require_columns_for(
7027                    table,
7028                    crate::auth::ColumnOperation::Select,
7029                    &[embedding_column],
7030                    principal,
7031                )?;
7032                if let Some(ann) = table_guard.ann_index(embedding_column) {
7033                    if let Some(active) = ann.semantic_identity() {
7034                        if active != &semantic_identity {
7035                            return Err(embedding_error(
7036                                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7037                                    column_id: embedding_column,
7038                                    expected: semantic_identity.fingerprint_sha256(),
7039                                    got: active.fingerprint_sha256(),
7040                                },
7041                            ));
7042                        }
7043                    }
7044                }
7045                table_guard.retrieve_at_with_candidate_authorization_on_generation(
7046                    &retriever,
7047                    snapshot,
7048                    authorization,
7049                    None,
7050                )
7051            },
7052        )?;
7053        Ok(crate::embedding::TextRetrieveResult {
7054            hits,
7055            provenance: crate::embedding::TextRetrieveProvenance {
7056                semantic_identity,
7057                provider_registry_generation: registry_generation,
7058                query_source_fingerprint: Sha256::digest(text.as_bytes()).into(),
7059                embedding_column,
7060            },
7061        })
7062    }
7063
7064    /// Capture one table snapshot and the security version used to authorize it.
7065    /// The caller must validate the returned version before publishing results.
7066    pub fn authorized_read_snapshot(
7067        &self,
7068        table: &str,
7069        principal: Option<&crate::auth::Principal>,
7070    ) -> Result<AuthorizedReadSnapshot> {
7071        let (security, security_version, effective_principal) = {
7072            let catalog = self.catalog.read();
7073            (
7074                catalog.security.clone(),
7075                catalog.security_version,
7076                self.principal_for_authorized_read(&catalog, principal, false)?,
7077            )
7078        };
7079        let handle = self.table(table)?;
7080        let (table_snapshot, data_generation, allowed_row_ids) = {
7081            let table_handle = handle.lock();
7082            let table_snapshot = table_handle.snapshot();
7083            let data_generation = table_handle.data_generation();
7084            let allowed = self.allowed_row_ids_locked(
7085                table,
7086                &table_handle,
7087                table_snapshot,
7088                (&security, security_version),
7089                effective_principal.as_ref(),
7090                None,
7091            )?;
7092            (
7093                table_snapshot,
7094                data_generation,
7095                allowed.map(|allowed| (*allowed).clone()),
7096            )
7097        };
7098        Ok(AuthorizedReadSnapshot {
7099            table: table.to_string(),
7100            table_snapshot,
7101            data_generation,
7102            security_version,
7103            allowed_row_ids,
7104        })
7105    }
7106
7107    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
7108        if self.catalog.read().security_version != snapshot.security_version {
7109            return false;
7110        }
7111        self.table(&snapshot.table)
7112            .ok()
7113            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
7114    }
7115
7116    pub fn rls_cache_stats(&self) -> RlsCacheStats {
7117        self.rls_cache.lock().stats()
7118    }
7119
7120    /// Read visible rows with column authorization, RLS, and masks applied.
7121    pub fn rows_for(
7122        &self,
7123        table: &str,
7124        principal: Option<&crate::auth::Principal>,
7125    ) -> Result<Vec<crate::memtable::Row>> {
7126        if principal.is_none() && self.principal.read().is_some() {
7127            self.refresh_principal()?;
7128        }
7129        let allowed = self.select_column_ids_for(table, principal)?;
7130        let handle = self.table(table)?;
7131        let rows = {
7132            let table = handle.lock();
7133            table.visible_rows(table.snapshot())?
7134        };
7135        let mut rows = self.secure_rows_for(table, rows, principal)?;
7136        for row in &mut rows {
7137            row.columns.retain(|column, _| allowed.contains(column));
7138        }
7139        Ok(rows)
7140    }
7141
7142    /// Historical rows use the current principal and security catalog against
7143    /// the row values visible at the requested snapshot.
7144    pub fn rows_at_epoch_for_current_principal(
7145        &self,
7146        table_name: &str,
7147        snapshot: Snapshot,
7148    ) -> Result<Vec<crate::memtable::Row>> {
7149        self.with_authorized_read_context(
7150            table_name,
7151            None,
7152            true,
7153            Some(&ReadAuthorization {
7154                operation: crate::auth::ColumnOperation::Select,
7155                columns: Vec::new(),
7156                permissions: Vec::new(),
7157            }),
7158            None,
7159            Some(snapshot),
7160            |table, snapshot, allowed, principal| {
7161                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
7162                let mut rows = table.visible_rows(snapshot)?;
7163                if let Some(allowed) = allowed {
7164                    rows.retain(|row| allowed.contains(&row.row_id));
7165                }
7166                rows = self.secure_rows_for(table_name, rows, principal)?;
7167                for row in &mut rows {
7168                    row.columns
7169                        .retain(|column, _| allowed_columns.contains(column));
7170                }
7171                Ok(rows)
7172            },
7173        )
7174    }
7175
7176    /// Count rows visible to a principal without bypassing RLS.
7177    pub fn count_for(
7178        &self,
7179        table: &str,
7180        principal: Option<&crate::auth::Principal>,
7181    ) -> Result<u64> {
7182        if principal.is_none() && self.principal.read().is_some() {
7183            self.refresh_principal()?;
7184        }
7185        self.select_column_ids_for(table, principal)?;
7186        if self.security_active_for(table) {
7187            Ok(self.rows_for(table, principal)?.len() as u64)
7188        } else {
7189            Ok(self.table(table)?.lock().count())
7190        }
7191    }
7192
7193    /// Authorize and write one native-API row for an explicit principal.
7194    pub fn put_for(
7195        &self,
7196        table: &str,
7197        mut cells: Vec<(u16, crate::memtable::Value)>,
7198        principal: Option<&crate::auth::Principal>,
7199    ) -> Result<RowId> {
7200        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
7201        self.require_columns_for(
7202            table,
7203            crate::auth::ColumnOperation::Insert,
7204            &columns,
7205            principal,
7206        )?;
7207        let handle = self.table(table)?;
7208        let mut table_handle = handle.lock();
7209        table_handle.fill_auto_inc(&mut cells)?;
7210        table_handle.apply_defaults(&mut cells)?;
7211        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
7212        row.columns.extend(cells.iter().cloned());
7213        self.check_row_policy_for(
7214            table,
7215            crate::security::PolicyCommand::Insert,
7216            &row,
7217            true,
7218            principal,
7219        )?;
7220        table_handle.put(cells)
7221    }
7222
7223    pub fn check_row_policy_for(
7224        &self,
7225        table: &str,
7226        command: crate::security::PolicyCommand,
7227        row: &crate::memtable::Row,
7228        check_new: bool,
7229        principal: Option<&crate::auth::Principal>,
7230    ) -> Result<()> {
7231        let security = self.catalog.read().security.clone();
7232        if !security.rls_enabled(table) {
7233            return Ok(());
7234        }
7235        let cached = self.principal.read().clone();
7236        let principal = principal
7237            .or(cached.as_ref())
7238            .ok_or(MongrelError::AuthRequired)?;
7239        if security.row_allowed(table, command, row, principal, check_new) {
7240            return Ok(());
7241        }
7242        let required = match command {
7243            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
7244                table: table.to_string(),
7245            },
7246            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
7247                table: table.to_string(),
7248            },
7249            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
7250                table: table.to_string(),
7251            },
7252            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
7253                crate::auth::Permission::Delete {
7254                    table: table.to_string(),
7255                }
7256            }
7257        };
7258        Err(MongrelError::PermissionDenied {
7259            required,
7260            principal: principal.username.clone(),
7261        })
7262    }
7263
7264    /// Durably create or replace a materialized-view definition after its
7265    /// physical table has been populated.
7266    pub fn set_materialized_view(
7267        &self,
7268        definition: crate::catalog::MaterializedViewEntry,
7269    ) -> Result<()> {
7270        self.set_materialized_view_with_epoch(definition)
7271            .map(|_| ())
7272    }
7273
7274    /// Durably create or replace a materialized-view definition and return its epoch.
7275    pub fn set_materialized_view_with_epoch(
7276        &self,
7277        definition: crate::catalog::MaterializedViewEntry,
7278    ) -> Result<Epoch> {
7279        use crate::wal::DdlOp;
7280        use std::sync::atomic::Ordering;
7281
7282        let command = crate::catalog_cmds::CatalogCommand::CreateMaterializedView {
7283            definition: definition.clone(),
7284        };
7285        self.require(&crate::catalog_cmds::required_permission(&command))?;
7286        if self.poisoned.load(Ordering::Relaxed) {
7287            return Err(MongrelError::Other(
7288                "database poisoned by fsync error".into(),
7289            ));
7290        }
7291        if definition.name.is_empty() || definition.query.trim().is_empty() {
7292            return Err(MongrelError::InvalidArgument(
7293                "materialized view name and query must not be empty".into(),
7294            ));
7295        }
7296        // S1A-004: admit the materialized-view replacement as one core
7297        // operation.
7298        let _operation = self.admit_operation()?;
7299        let _ddl = self.ddl_lock.lock();
7300        let _security_write = self.security_write()?;
7301        self.require(&crate::catalog_cmds::required_permission(&command))?;
7302        // S1F-001: the backing-table check validates pure against the current
7303        // catalog first so it surfaces before an epoch is consumed, exactly
7304        // like the legacy pre-bump lookup.
7305        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7306        let table_id = self
7307            .catalog
7308            .read()
7309            .live(&definition.name)
7310            .ok_or_else(|| {
7311                MongrelError::NotFound(format!(
7312                    "materialized view table {:?} not found",
7313                    definition.name
7314                ))
7315            })?
7316            .table_id;
7317        let definition_json = DdlOp::encode_materialized_view(&definition)?;
7318        let _commit = self.commit_lock.lock();
7319        let epoch = self.epoch.bump_assigned();
7320        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7321        let txn_id = self.alloc_txn_id()?;
7322        let mut next_catalog = self.catalog.read().clone();
7323        self.apply_catalog_command_to(&mut next_catalog, command)?;
7324        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
7325        let commit_seq = {
7326            let mut wal = self.shared_wal.lock();
7327            let append: Result<u64> = (|| {
7328                wal.append(
7329                    txn_id,
7330                    table_id,
7331                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
7332                        name: definition.name.clone(),
7333                        definition_json,
7334                    }),
7335                )?;
7336                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
7337                wal.append_commit(txn_id, epoch, &[])
7338            })();
7339            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
7340        };
7341        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
7342
7343        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
7344        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
7345        Ok(epoch)
7346    }
7347
7348    /// The filesystem root this database was opened/created at.
7349    pub fn root(&self) -> &Path {
7350        self.durable_root.canonical_path()
7351    }
7352
7353    /// Open a descriptor-pinned view of this database root for durable
7354    /// extension state such as server idempotency receipts.
7355    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
7356        Arc::clone(&self.durable_root)
7357    }
7358
7359    /// Domain-separated authentication key for server idempotency state.
7360    /// Encrypted databases derive it from the in-memory KEK. Plain databases
7361    /// return `None`; their server persists a random key under the pinned root.
7362    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
7363        self.kek
7364            .as_deref()
7365            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
7366    }
7367
7368    pub fn is_read_only_replica(&self) -> bool {
7369        self.read_only
7370    }
7371
7372    /// Reject reads whose backing state may require WAL recovery after a
7373    /// post-commit publication failure. Ordinary table/catalog state is made
7374    /// coherent before poison; file-backed external modules use this gate.
7375    pub fn ensure_consistent_read(&self) -> Result<()> {
7376        self.ensure_owner_process()?;
7377        if self.poisoned.load(Ordering::Relaxed) {
7378            return Err(MongrelError::Other(
7379                "database poisoned by post-commit failure; reopen required".into(),
7380            ));
7381        }
7382        Ok(())
7383    }
7384
7385    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
7386        self.replication_wal_retention_segments
7387            .store(segments, std::sync::atomic::Ordering::Relaxed);
7388    }
7389
7390    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
7391    /// direct table commits, compaction, and WAL append are quiesced while the
7392    /// file image is read. WAL records newer than manifests remain sufficient
7393    /// for recovery, so no flush or compaction is required.
7394    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
7395        let admin = crate::auth::Permission::Admin;
7396        self.require(&admin)?;
7397        // S1A-004: admit the bootstrap capture as one core operation.
7398        let _operation = self.admit_operation()?;
7399        let operation_principal = self.principal_snapshot();
7400        let _barrier = self.replication_barrier.write();
7401        let _ddl = self.ddl_lock.lock();
7402        let _security = self.security_coordinator.gate.read();
7403        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7404        let mut handles: Vec<_> = self
7405            .tables
7406            .read()
7407            .iter()
7408            .map(|(id, handle)| (*id, handle.clone()))
7409            .collect();
7410        handles.sort_by_key(|(id, _)| *id);
7411        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7412        let _commit = self.commit_lock.lock();
7413        let mut wal = self.shared_wal.lock();
7414        wal.group_sync()?;
7415        let io_root = self.durable_root.io_path()?;
7416        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7417        let records = crate::wal::SharedWal::replay_with_dek(&io_root, wal_dek.as_ref())?;
7418        let epoch = records
7419            .iter()
7420            .filter_map(|record| match &record.op {
7421                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
7422                _ => None,
7423            })
7424            .max()
7425            .unwrap_or(0)
7426            .max(self.epoch.committed().0);
7427        let files = crate::replication::capture_files(&io_root)?;
7428        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7429        drop(wal);
7430        Ok(crate::replication::ReplicationSnapshot::new(
7431            source_id, epoch, files,
7432        ))
7433    }
7434
7435    /// Create an online, directly-openable backup at `destination`.
7436    ///
7437    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
7438    /// mutable metadata, and pins the exact immutable runs named by the copied
7439    /// manifests. Writers resume while those runs stream into a sibling staging
7440    /// directory. A checksummed backup manifest is written last, then the stage
7441    /// is atomically renamed into place.
7442    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
7443        let control = crate::ExecutionControl::new(None);
7444        self.hot_backup_controlled(destination, &control, || true)
7445    }
7446
7447    pub(crate) fn hot_backup_to_durable_child(
7448        &self,
7449        parent: &crate::durable_file::DurableRoot,
7450        child: &Path,
7451        control: &crate::ExecutionControl,
7452    ) -> Result<crate::backup::BackupReport> {
7453        let mut components = child.components();
7454        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
7455            || components.next().is_some()
7456        {
7457            return Err(MongrelError::InvalidArgument(
7458                "durable backup child must be one relative path component".into(),
7459            ));
7460        }
7461        let destination_name = child.file_name().ok_or_else(|| {
7462            MongrelError::InvalidArgument("durable backup child has no filename".into())
7463        })?;
7464        let source_root = self.durable_root.io_path()?;
7465        let prepared = prepare_backup_destination_in(&source_root, parent, destination_name)?;
7466        self.hot_backup_prepared(prepared, control, || true)
7467    }
7468
7469    /// Build a backup cooperatively, then invoke `before_publish` immediately
7470    /// before the staging directory is atomically renamed into place.
7471    #[doc(hidden)]
7472    pub fn hot_backup_controlled<F>(
7473        &self,
7474        destination: impl AsRef<Path>,
7475        control: &crate::ExecutionControl,
7476        before_publish: F,
7477    ) -> Result<crate::backup::BackupReport>
7478    where
7479        F: FnOnce() -> bool,
7480    {
7481        let source_root = self.durable_root.io_path()?;
7482        let prepared = prepare_backup_destination(&source_root, destination.as_ref())?;
7483        self.hot_backup_prepared(prepared, control, before_publish)
7484    }
7485
7486    fn hot_backup_prepared<F>(
7487        &self,
7488        mut prepared: PreparedBackupDestination,
7489        control: &crate::ExecutionControl,
7490        before_publish: F,
7491    ) -> Result<crate::backup::BackupReport>
7492    where
7493        F: FnOnce() -> bool,
7494    {
7495        let admin = crate::auth::Permission::Admin;
7496        self.require(&admin)?;
7497        // S1A-004: admit the backup as one core operation; `shutdown()` waits
7498        // for it to drain instead of closing the core mid-copy.
7499        let _operation = self.admit_operation()?;
7500        let operation_principal = self.principal_snapshot();
7501        control.checkpoint()?;
7502        let destination = prepared.destination_path.clone();
7503        let mut before_publish = Some(before_publish);
7504
7505        let outcome = (|| {
7506            control.checkpoint()?;
7507            let barrier = self.replication_barrier.write();
7508            let ddl = self.ddl_lock.lock();
7509            let security = self.security_coordinator.gate.read();
7510            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7511            let mut handles: Vec<_> = self
7512                .tables
7513                .read()
7514                .iter()
7515                .map(|(id, handle)| (*id, handle.clone()))
7516                .collect();
7517            handles.sort_by_key(|(id, _)| *id);
7518            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7519            let commit = self.commit_lock.lock();
7520            let mut wal = self.shared_wal.lock();
7521            wal.group_sync()?;
7522            let epoch = self.epoch.committed().0;
7523            let boundary_unix_nanos = current_unix_nanos();
7524            let source_root = self.durable_root.io_path()?;
7525
7526            let pin_nonce = std::time::SystemTime::now()
7527                .duration_since(std::time::UNIX_EPOCH)
7528                .unwrap_or_default()
7529                .as_nanos();
7530            let file_pin_root = source_root
7531                .join(META_DIR)
7532                .join("backup-pins")
7533                .join(format!("{}-{pin_nonce}", std::process::id()));
7534            std::fs::create_dir_all(&file_pin_root)?;
7535            let _file_pins = BackupFilePins {
7536                root: file_pin_root.clone(),
7537            };
7538            let mut run_files = Vec::new();
7539            for (index, (table_id, _)) in handles.iter().enumerate() {
7540                if index % 256 == 0 {
7541                    control.checkpoint()?;
7542                }
7543                let table = &table_guards[index];
7544                for (run_index, run) in table.run_refs().iter().enumerate() {
7545                    if run_index % 256 == 0 {
7546                        control.checkpoint()?;
7547                    }
7548                    let source = table.run_path(run.run_id as u64);
7549                    let relative = Path::new(TABLES_DIR)
7550                        .join(table_id.to_string())
7551                        .join(crate::engine::RUNS_DIR)
7552                        .join(format!("r-{}.sr", run.run_id));
7553                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
7554                    if std::fs::hard_link(&source, &pinned).is_err() {
7555                        crate::backup::copy_file_synced(&source, &pinned)?;
7556                    }
7557                    run_files.push(((*table_id, run.run_id), pinned, relative));
7558                }
7559            }
7560            crate::durable_file::sync_directory(&file_pin_root)?;
7561            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
7562            {
7563                let mut pins = self.backup_pins.lock();
7564                for key in &run_keys {
7565                    *pins.entry(*key).or_insert(0) += 1;
7566                }
7567            }
7568            let _run_pins = RunPins {
7569                pins: Arc::clone(&self.backup_pins),
7570                runs: run_keys,
7571            };
7572            // S1C-004: mirror the backup boundary into every table's unified
7573            // pin registry (PinSource::BackupPitr covers PITR base captures,
7574            // which route through this same path). Version GC must not
7575            // reclaim the boundary versions while their runs stream into the
7576            // stage, and diagnostics must expose the pin. The guards drop
7577            // with `_run_pins` when the backup finishes or fails.
7578            let _registry_pins: Vec<crate::retention::PinGuard> = table_guards
7579                .iter()
7580                .map(|table| {
7581                    table
7582                        .pin_registry()
7583                        .pin(crate::retention::PinSource::BackupPitr, Epoch(epoch))
7584                })
7585                .collect();
7586            let deferred: HashSet<_> = run_files
7587                .iter()
7588                .map(|(_, _, relative)| relative.clone())
7589                .collect();
7590            let mut copied = Vec::new();
7591            copy_backup_boundary(
7592                &source_root,
7593                prepared.stage.as_deref().ok_or_else(|| {
7594                    MongrelError::Other("backup staging root was already released".into())
7595                })?,
7596                &deferred,
7597                &mut copied,
7598                Some(control),
7599            )?;
7600
7601            drop(wal);
7602            drop(commit);
7603            drop(table_guards);
7604            drop(security);
7605            drop(ddl);
7606            drop(barrier);
7607
7608            if let Some(hook) = self.backup_hook.lock().as_ref() {
7609                hook();
7610            }
7611            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
7612                if index % 256 == 0 {
7613                    control.checkpoint()?;
7614                }
7615                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
7616                prepared
7617                    .stage
7618                    .as_deref()
7619                    .ok_or_else(|| {
7620                        MongrelError::Other("backup staging root was already released".into())
7621                    })?
7622                    .copy_new_from(&relative, &mut source)?;
7623                copied.push(relative);
7624            }
7625
7626            let manifest = crate::backup::BackupManifest::create_controlled_durable(
7627                prepared.stage.as_deref().ok_or_else(|| {
7628                    MongrelError::Other("backup staging root was already released".into())
7629                })?,
7630                epoch,
7631                &copied,
7632                control,
7633            )?;
7634            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
7635                MongrelError::Other("backup staging root was already released".into())
7636            })?)?;
7637            control.checkpoint()?;
7638            let publish = before_publish.take().ok_or_else(|| {
7639                MongrelError::Other("backup publication callback already consumed".into())
7640            })?;
7641            if !publish() {
7642                return Err(MongrelError::Cancelled);
7643            }
7644            let final_security = self.security_coordinator.gate.read();
7645            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7646            // Windows pins directories without delete sharing. Release the
7647            // stage handle before renaming that directory, while the parent
7648            // remains descriptor-pinned for the no-replace publication.
7649            drop(prepared.stage.take().ok_or_else(|| {
7650                MongrelError::Other("backup staging root was already released".into())
7651            })?);
7652            let published = std::cell::Cell::new(false);
7653            if let Err(error) = prepared.parent.rename_directory_new_with_after(
7654                Path::new(&prepared.stage_name),
7655                &prepared.parent,
7656                Path::new(&prepared.destination_name),
7657                || published.set(true),
7658            ) {
7659                if published.get() {
7660                    return Err(MongrelError::CommitOutcomeUnknown {
7661                        epoch,
7662                        message: format!("backup publication was not durable: {error}"),
7663                    });
7664                }
7665                return Err(error.into());
7666            }
7667            drop(final_security);
7668            Ok(crate::backup::BackupReport {
7669                destination,
7670                epoch,
7671                boundary_unix_nanos,
7672                files: manifest.files.len(),
7673                bytes: manifest.total_bytes(),
7674            })
7675        })();
7676
7677        if outcome.is_err() {
7678            drop(prepared.stage.take());
7679            let _ = prepared
7680                .parent
7681                .remove_directory_all(Path::new(&prepared.stage_name));
7682        }
7683        outcome
7684    }
7685
7686    /// Return complete committed transactions after `since_epoch`. A gap or a
7687    /// transaction backed by a spilled run requires a fresh bootstrap image.
7688    pub fn replication_batch_since(
7689        &self,
7690        since_epoch: u64,
7691    ) -> Result<crate::replication::ReplicationBatch> {
7692        use crate::wal::Op;
7693
7694        let admin = crate::auth::Permission::Admin;
7695        self.require(&admin)?;
7696        // S1A-004: admit the batch extraction as one core operation.
7697        let _operation = self.admit_operation()?;
7698        let operation_principal = self.principal_snapshot();
7699
7700        let mut wal = self.shared_wal.lock();
7701        wal.group_sync()?;
7702        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7703        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
7704        drop(wal);
7705
7706        let commits: HashMap<u64, u64> = records
7707            .iter()
7708            .filter_map(|record| match &record.op {
7709                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
7710                _ => None,
7711            })
7712            .collect();
7713        let earliest_epoch = commits.values().copied().min();
7714        let current_epoch = commits
7715            .values()
7716            .copied()
7717            .max()
7718            .unwrap_or(0)
7719            .max(self.epoch.committed().0);
7720        let selected: HashSet<u64> = commits
7721            .iter()
7722            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
7723            .collect();
7724        let retention_gap = since_epoch < current_epoch
7725            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
7726        let spilled = records.iter().any(|record| {
7727            selected.contains(&record.txn_id)
7728                && matches!(
7729                    &record.op,
7730                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
7731                )
7732        });
7733        let records = records
7734            .into_iter()
7735            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
7736            .filter(|record| selected.contains(&record.txn_id))
7737            .collect();
7738        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7739        let batch = crate::replication::ReplicationBatch::complete_for_source(
7740            source_id,
7741            since_epoch,
7742            current_epoch,
7743            earliest_epoch,
7744            retention_gap,
7745            spilled,
7746            records,
7747        )?;
7748        if let Some(hook) = self.replication_hook.lock().as_ref() {
7749            hook();
7750        }
7751        let _security = self.security_coordinator.gate.read();
7752        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7753        Ok(batch)
7754    }
7755
7756    /// Durably append a leader batch to a follower's local WAL and checkpoint
7757    /// its catalog metadata. Security changes apply to this live handle before
7758    /// success returns. The caller must reopen to mount new table state.
7759    pub fn append_replication_batch(
7760        &self,
7761        batch: &crate::replication::ReplicationBatch,
7762    ) -> Result<u64> {
7763        use crate::wal::Op;
7764
7765        if !self.read_only {
7766            return Err(MongrelError::InvalidArgument(
7767                "replication batches may only target a marked replica".into(),
7768            ));
7769        }
7770        // S1A-004: admit the follow-apply as one core operation.
7771        let _operation = self.admit_operation()?;
7772        let current = crate::replication::replica_epoch(&self.root)?;
7773        if batch.is_source_bound() {
7774            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
7775            if batch.source_id != source_id {
7776                return Err(MongrelError::Conflict(
7777                    "replication batch source does not match follower binding".into(),
7778                ));
7779            }
7780        }
7781        if batch.requires_snapshot {
7782            return Err(MongrelError::Conflict(
7783                "replication snapshot required for this batch".into(),
7784            ));
7785        }
7786        batch.validate_proof()?;
7787        if batch.from_epoch != current {
7788            if batch.from_epoch < current && batch.current_epoch == current {
7789                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7790                let _wal = self.shared_wal.lock();
7791                let existing: HashSet<(u64, u64)> =
7792                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7793                        .into_iter()
7794                        .filter_map(|record| match record.op {
7795                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7796                            _ => None,
7797                        })
7798                        .collect();
7799                let already_applied = batch.records.iter().all(|record| match &record.op {
7800                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
7801                    _ => true,
7802                });
7803                if already_applied {
7804                    return Ok(current);
7805                }
7806            }
7807            return Err(MongrelError::Conflict(format!(
7808                "replication batch starts at epoch {}, follower is at epoch {current}",
7809                batch.from_epoch
7810            )));
7811        }
7812        if batch.current_epoch < current {
7813            return Err(MongrelError::InvalidArgument(format!(
7814                "replication batch current epoch {} precedes follower epoch {current}",
7815                batch.current_epoch
7816            )));
7817        }
7818        let records = &batch.records;
7819        let mut commits = HashMap::new();
7820        let mut commit_epochs = HashSet::new();
7821        let mut commit_timestamps = HashMap::new();
7822        for record in records {
7823            match &record.op {
7824                Op::TxnCommit { epoch, added_runs } => {
7825                    if !added_runs.is_empty() {
7826                        return Err(MongrelError::Conflict(
7827                            "replication snapshot required for spilled-run transaction".into(),
7828                        ));
7829                    }
7830                    if commits.insert(record.txn_id, *epoch).is_some() {
7831                        return Err(MongrelError::InvalidArgument(format!(
7832                            "duplicate commit for replication transaction {}",
7833                            record.txn_id
7834                        )));
7835                    }
7836                    if *epoch <= current || *epoch > batch.current_epoch {
7837                        return Err(MongrelError::InvalidArgument(format!(
7838                            "replication commit epoch {epoch} is outside ({current}, {}]",
7839                            batch.current_epoch
7840                        )));
7841                    }
7842                    if !commit_epochs.insert(*epoch) {
7843                        return Err(MongrelError::InvalidArgument(format!(
7844                            "duplicate replication commit epoch {epoch}"
7845                        )));
7846                    }
7847                }
7848                Op::CommitTimestamp { unix_nanos } => {
7849                    commit_timestamps.insert(record.txn_id, *unix_nanos);
7850                }
7851                _ => {}
7852            }
7853        }
7854        for record in records {
7855            if record.txn_id == crate::wal::SYSTEM_TXN_ID
7856                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
7857            {
7858                return Err(MongrelError::InvalidArgument(
7859                    "replication batch contains a non-committed record".into(),
7860                ));
7861            }
7862            if !commits.contains_key(&record.txn_id) {
7863                return Err(MongrelError::InvalidArgument(format!(
7864                    "incomplete replication transaction {}",
7865                    record.txn_id
7866                )));
7867            }
7868        }
7869        let target_epoch = commits
7870            .values()
7871            .copied()
7872            .filter(|epoch| *epoch > current)
7873            .max()
7874            .unwrap_or(current);
7875        if target_epoch != batch.current_epoch {
7876            return Err(MongrelError::InvalidArgument(format!(
7877                "replication batch ends at epoch {target_epoch}, expected {}",
7878                batch.current_epoch
7879            )));
7880        }
7881        let mut selected: HashSet<u64> = commits
7882            .iter()
7883            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
7884            .collect();
7885        if selected.is_empty() {
7886            return Ok(current);
7887        }
7888        let mut wal = self.shared_wal.lock();
7889        wal.group_sync()?;
7890        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7891        let existing: HashSet<(u64, u64)> =
7892            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7893                .into_iter()
7894                .filter_map(|record| match record.op {
7895                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7896                    _ => None,
7897                })
7898                .collect();
7899        selected.retain(|txn_id| {
7900            commits
7901                .get(txn_id)
7902                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
7903        });
7904        for record in records {
7905            if !selected.contains(&record.txn_id) {
7906                continue;
7907            }
7908            match &record.op {
7909                Op::TxnCommit { epoch, added_runs } => {
7910                    let timestamp = commit_timestamps
7911                        .get(&record.txn_id)
7912                        .copied()
7913                        .unwrap_or_else(current_unix_nanos);
7914                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
7915                }
7916                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
7917                op => {
7918                    wal.append(record.txn_id, 0, op.clone())?;
7919                }
7920            }
7921        }
7922        if !selected.is_empty() {
7923            wal.group_sync()?;
7924        }
7925        drop(wal);
7926
7927        // Auth mode is selected before `finish_open` replays the WAL. Make the
7928        // catalog transition durable and publish security state to this live
7929        // handle before reporting success.
7930        let mut recovered_catalog = self.catalog.read().clone();
7931        if let Err(error) = recover_ddl_from_wal(
7932            &self.root,
7933            Some(&self.durable_root),
7934            &mut recovered_catalog,
7935            self.meta_dek.as_ref(),
7936            wal_dek.as_ref(),
7937            true,
7938            None,
7939        ) {
7940            return Err(MongrelError::DurableCommit {
7941                epoch: target_epoch,
7942                message: format!(
7943                    "replication WAL is durable but catalog checkpoint failed: {error}"
7944                ),
7945            });
7946        }
7947        let _security = self.security_coordinator.gate.write();
7948        let old_security_version = self.catalog.read().security_version;
7949        let security_changed = old_security_version != recovered_catalog.security_version
7950            || self.catalog.read().require_auth != recovered_catalog.require_auth;
7951        let require_auth = recovered_catalog.require_auth;
7952        let principal = if security_changed {
7953            None
7954        } else {
7955            self.principal.read().as_ref().and_then(|principal| {
7956                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
7957            })
7958        };
7959        if require_auth {
7960            self.auth_state.set_require_auth(true);
7961        }
7962        self.auth_state.set_principal(principal.clone());
7963        *self.principal.write() = principal;
7964        let security_version = recovered_catalog.security_version;
7965        *self.catalog.write() = recovered_catalog;
7966        self.security_coordinator
7967            .version
7968            .store(security_version, Ordering::Release);
7969        if !require_auth {
7970            self.auth_state.set_require_auth(false);
7971        }
7972        if let Err(error) =
7973            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
7974        {
7975            return Err(MongrelError::DurableCommit {
7976                epoch: target_epoch,
7977                message: format!(
7978                    "replication WAL and catalog are durable but follower watermark failed: {error}"
7979                ),
7980            });
7981        }
7982        Ok(target_epoch)
7983    }
7984
7985    /// Resolve a table name → id (live tables only). pub(crate) so the
7986    /// transaction layer can stage by name.
7987    pub fn table_id(&self, name: &str) -> Result<u64> {
7988        let cat = self.catalog.read();
7989        cat.live(name)
7990            .map(|e| e.table_id)
7991            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
7992    }
7993
7994    /// Return the stable table id and current schema generation from one
7995    /// catalog snapshot. Callers can bind retries to this identity so a table
7996    /// dropped and recreated under the same name is never mistaken for the
7997    /// original resource.
7998    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
7999        let catalog = self.catalog.read();
8000        catalog
8001            .live(name)
8002            .map(|entry| (entry.table_id, entry.schema.schema_id))
8003            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
8004    }
8005
8006    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
8007        self.catalog
8008            .read()
8009            .building(name)
8010            .map(|entry| entry.table_id)
8011            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
8012    }
8013
8014    pub fn procedures(&self) -> Vec<StoredProcedure> {
8015        self.catalog
8016            .read()
8017            .procedures
8018            .iter()
8019            .map(|p| p.procedure.clone())
8020            .collect()
8021    }
8022
8023    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
8024        self.catalog
8025            .read()
8026            .procedures
8027            .iter()
8028            .find(|p| p.procedure.name == name)
8029            .map(|p| p.procedure.clone())
8030    }
8031
8032    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
8033        self.create_procedure_inner(procedure, None)
8034    }
8035
8036    pub fn create_procedure_controlled<F>(
8037        &self,
8038        procedure: StoredProcedure,
8039        mut before_publish: F,
8040    ) -> Result<StoredProcedure>
8041    where
8042        F: FnMut() -> Result<()>,
8043    {
8044        self.create_procedure_inner(procedure, Some(&mut before_publish))
8045    }
8046
8047    fn create_procedure_inner(
8048        &self,
8049        mut procedure: StoredProcedure,
8050        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8051    ) -> Result<StoredProcedure> {
8052        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8053            procedure: procedure.clone(),
8054        };
8055        self.require(&crate::catalog_cmds::required_permission(&command))?;
8056        let _g = self.ddl_lock.lock();
8057        let _security_write = self.security_write()?;
8058        self.require(&crate::catalog_cmds::required_permission(&command))?;
8059        procedure.validate()?;
8060        self.validate_procedure_references(&procedure)?;
8061        // S1F-001: validation runs pure against the current catalog first so
8062        // failures (duplicate name, unknown table) surface before an epoch is
8063        // consumed, exactly like the legacy pre-bump checks.
8064        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8065        let commit_lock = Arc::clone(&self.commit_lock);
8066        let _c = commit_lock.lock();
8067        let epoch = self.epoch.bump_assigned();
8068        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8069        procedure.created_epoch = epoch.0;
8070        procedure.updated_epoch = epoch.0;
8071        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8072            procedure: procedure.clone(),
8073        };
8074        let mut next_catalog = self.catalog.read().clone();
8075        self.apply_catalog_command_to(&mut next_catalog, command)?;
8076        next_catalog.db_epoch = epoch.0;
8077        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8078        Ok(procedure)
8079    }
8080
8081    pub fn create_or_replace_procedure(
8082        &self,
8083        procedure: StoredProcedure,
8084    ) -> Result<StoredProcedure> {
8085        self.create_or_replace_procedure_inner(procedure, None)
8086    }
8087
8088    pub fn create_or_replace_procedure_controlled<F>(
8089        &self,
8090        procedure: StoredProcedure,
8091        mut before_publish: F,
8092    ) -> Result<StoredProcedure>
8093    where
8094        F: FnMut() -> Result<()>,
8095    {
8096        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
8097    }
8098
8099    fn create_or_replace_procedure_inner(
8100        &self,
8101        procedure: StoredProcedure,
8102        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8103    ) -> Result<StoredProcedure> {
8104        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8105            procedure: procedure.clone(),
8106        };
8107        self.require(&crate::catalog_cmds::required_permission(&command))?;
8108        let _g = self.ddl_lock.lock();
8109        let _security_write = self.security_write()?;
8110        self.require(&crate::catalog_cmds::required_permission(&command))?;
8111        procedure.validate()?;
8112        self.validate_procedure_references(&procedure)?;
8113        // S1F-001: validation runs pure against the current catalog first so
8114        // structural failures surface before an epoch is consumed, exactly
8115        // like the legacy pre-bump checks.
8116        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8117        let commit_lock = Arc::clone(&self.commit_lock);
8118        let _c = commit_lock.lock();
8119        let epoch = self.epoch.bump_assigned();
8120        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8121        let mut next_catalog = self.catalog.read().clone();
8122        // Resolve the replacement image against the candidate catalog: the
8123        // engine stamps version/epochs from the commit epoch (preserving the
8124        // original created_epoch on replacement), then the command record
8125        // carries that resolved image.
8126        let replaced = match next_catalog
8127            .procedures
8128            .iter()
8129            .position(|p| p.procedure.name == procedure.name)
8130        {
8131            Some(idx) => next_catalog.procedures[idx]
8132                .procedure
8133                .replaced(procedure.clone(), epoch.0)?,
8134            None => {
8135                let mut next = procedure;
8136                next.created_epoch = epoch.0;
8137                next.updated_epoch = epoch.0;
8138                next
8139            }
8140        };
8141        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8142            procedure: replaced.clone(),
8143        };
8144        self.apply_catalog_command_to(&mut next_catalog, command)?;
8145        next_catalog.db_epoch = epoch.0;
8146        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8147        Ok(replaced)
8148    }
8149
8150    pub fn drop_procedure(&self, name: &str) -> Result<()> {
8151        self.drop_procedure_with_epoch(name).map(|_| ())
8152    }
8153
8154    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
8155        self.drop_procedure_with_epoch_inner(name, None)
8156    }
8157
8158    pub fn drop_procedure_with_epoch_controlled<F>(
8159        &self,
8160        name: &str,
8161        mut before_publish: F,
8162    ) -> Result<Epoch>
8163    where
8164        F: FnMut() -> Result<()>,
8165    {
8166        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
8167    }
8168
8169    fn drop_procedure_with_epoch_inner(
8170        &self,
8171        name: &str,
8172        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8173    ) -> Result<Epoch> {
8174        let command = crate::catalog_cmds::CatalogCommand::DropProcedure {
8175            name: name.to_string(),
8176        };
8177        self.require(&crate::catalog_cmds::required_permission(&command))?;
8178        let _g = self.ddl_lock.lock();
8179        let _security_write = self.security_write()?;
8180        self.require(&crate::catalog_cmds::required_permission(&command))?;
8181        let commit_lock = Arc::clone(&self.commit_lock);
8182        let _c = commit_lock.lock();
8183        let epoch = self.epoch.bump_assigned();
8184        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8185        let mut next_catalog = self.catalog.read().clone();
8186        self.apply_catalog_command_to(&mut next_catalog, command)?;
8187        next_catalog.db_epoch = epoch.0;
8188        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8189        Ok(epoch)
8190    }
8191
8192    // ── User / role / credentials management ─────────────────────────────
8193
8194    /// List all catalog users (password hashes included — callers should not
8195    /// serialize them externally).
8196    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
8197        self.catalog.read().users.clone()
8198    }
8199
8200    /// Resolve only the stable, non-secret identity fields needed to scope
8201    /// request receipts. Password hashes never leave the catalog lock.
8202    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
8203        self.catalog
8204            .read()
8205            .users
8206            .iter()
8207            .find(|user| user.username == username)
8208            .map(|user| (user.id, user.created_epoch))
8209    }
8210
8211    /// SCRAM verifier material for the current catalog user generation.
8212    pub fn user_scram_verifier(
8213        &self,
8214        username: &str,
8215    ) -> Option<crate::security_hardening::ScramVerifier> {
8216        self.catalog
8217            .read()
8218            .users
8219            .iter()
8220            .find(|user| user.username == username)
8221            .and_then(|user| user.scram_sha_256.clone())
8222    }
8223
8224    /// Current catalog authorization generation. Retry bindings can include
8225    /// this value to fail closed after roles, grants, or row policies change.
8226    pub fn security_version(&self) -> u64 {
8227        self.catalog.read().security_version
8228    }
8229
8230    /// List all catalog roles.
8231    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
8232        self.catalog.read().roles.clone()
8233    }
8234
8235    /// Create a new user with an Argon2id-hashed password.
8236    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
8237        self.require(&crate::auth::Permission::Admin)?;
8238        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
8239        let scram = crate::auth::scram_verifier(password).map_err(MongrelError::Other)?;
8240        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(password);
8241        self.create_user_with_password_hash_inner(username, hash, Some((scram, mysql)), None)
8242    }
8243
8244    /// Create a user from a password hash prepared before a commit fence.
8245    pub fn create_user_with_password_hash(
8246        &self,
8247        username: &str,
8248        hash: String,
8249    ) -> Result<crate::auth::UserEntry> {
8250        self.create_user_with_password_hash_inner(username, hash, None, None)
8251    }
8252
8253    pub fn create_user_with_password_hash_controlled<F>(
8254        &self,
8255        username: &str,
8256        hash: String,
8257        mut before_publish: F,
8258    ) -> Result<crate::auth::UserEntry>
8259    where
8260        F: FnMut() -> Result<()>,
8261    {
8262        self.create_user_with_password_hash_inner(username, hash, None, Some(&mut before_publish))
8263    }
8264
8265    fn create_user_with_password_hash_inner(
8266        &self,
8267        username: &str,
8268        hash: String,
8269        verifiers: Option<(
8270            crate::security_hardening::ScramVerifier,
8271            crate::auth::MysqlCachingSha2Verifier,
8272        )>,
8273        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8274    ) -> Result<crate::auth::UserEntry> {
8275        // S1F-001: the mutation is a versioned catalog command; its required
8276        // permission is checked against the caller principal first (identical
8277        // to the legacy hardcoded Admin gate).
8278        let command = match &verifiers {
8279            Some((scram_sha_256, mysql_caching_sha2)) => {
8280                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8281                    username: username.to_string(),
8282                    password_hash: hash.clone(),
8283                    scram_sha_256: scram_sha_256.clone(),
8284                    mysql_caching_sha2: mysql_caching_sha2.clone(),
8285                    is_admin: false,
8286                    created_epoch: 0,
8287                }
8288            }
8289            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8290                username: username.to_string(),
8291                password_hash: hash.clone(),
8292                is_admin: false,
8293                created_epoch: 0,
8294            },
8295        };
8296        self.require(&crate::catalog_cmds::required_permission(&command))?;
8297        let _ddl = self.ddl_lock.lock();
8298        let _security_write = self.security_write()?;
8299        self.require(&crate::catalog_cmds::required_permission(&command))?;
8300        let _commit = self.commit_lock.lock();
8301        let epoch = self.epoch.bump_assigned();
8302        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8303        let command = match verifiers {
8304            Some((scram_sha_256, mysql_caching_sha2)) => {
8305                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8306                    username: username.to_string(),
8307                    password_hash: hash,
8308                    scram_sha_256,
8309                    mysql_caching_sha2,
8310                    is_admin: false,
8311                    created_epoch: epoch.0,
8312                }
8313            }
8314            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8315                username: username.to_string(),
8316                password_hash: hash,
8317                is_admin: false,
8318                created_epoch: epoch.0,
8319            },
8320        };
8321        let mut next_catalog = self.catalog.read().clone();
8322        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8323        let crate::catalog_cmds::CatalogDelta::UserUpserted(entry) = delta else {
8324            return Err(MongrelError::Other(
8325                "CreateUser resolved to an unexpected catalog delta".into(),
8326            ));
8327        };
8328        next_catalog.db_epoch = epoch.0;
8329        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8330        Ok(entry)
8331    }
8332
8333    /// Drop a user by username.
8334    pub fn drop_user(&self, username: &str) -> Result<()> {
8335        self.drop_user_with_epoch(username).map(|_| ())
8336    }
8337
8338    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
8339        self.drop_user_with_epoch_inner(username, None)
8340    }
8341
8342    pub fn drop_user_with_epoch_controlled<F>(
8343        &self,
8344        username: &str,
8345        mut before_publish: F,
8346    ) -> Result<Epoch>
8347    where
8348        F: FnMut() -> Result<()>,
8349    {
8350        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
8351    }
8352
8353    fn drop_user_with_epoch_inner(
8354        &self,
8355        username: &str,
8356        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8357    ) -> Result<Epoch> {
8358        let command = crate::catalog_cmds::CatalogCommand::DropUser {
8359            username: username.to_string(),
8360        };
8361        self.require(&crate::catalog_cmds::required_permission(&command))?;
8362        let _ddl = self.ddl_lock.lock();
8363        let _security_write = self.security_write()?;
8364        self.require(&crate::catalog_cmds::required_permission(&command))?;
8365        let _commit = self.commit_lock.lock();
8366        let epoch = self.epoch.bump_assigned();
8367        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8368        let mut next_catalog = self.catalog.read().clone();
8369        self.apply_catalog_command_to(&mut next_catalog, command)?;
8370        next_catalog.db_epoch = epoch.0;
8371        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8372        Ok(epoch)
8373    }
8374
8375    /// Change a user's password.
8376    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
8377        self.alter_user_password_with_epoch(username, new_password)
8378            .map(|_| ())
8379    }
8380
8381    pub fn alter_user_password_with_epoch(
8382        &self,
8383        username: &str,
8384        new_password: &str,
8385    ) -> Result<Epoch> {
8386        self.require(&crate::auth::Permission::Admin)?;
8387        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
8388        let scram = crate::auth::scram_verifier(new_password).map_err(MongrelError::Other)?;
8389        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(new_password);
8390        self.alter_user_password_hash_with_epoch_inner(username, hash, Some((scram, mysql)), None)
8391    }
8392
8393    pub fn alter_user_password_hash_with_epoch(
8394        &self,
8395        username: &str,
8396        hash: String,
8397    ) -> Result<Epoch> {
8398        self.alter_user_password_hash_with_epoch_inner(username, hash, None, None)
8399    }
8400
8401    pub fn alter_user_password_hash_with_epoch_controlled<F>(
8402        &self,
8403        username: &str,
8404        hash: String,
8405        mut before_publish: F,
8406    ) -> Result<Epoch>
8407    where
8408        F: FnMut() -> Result<()>,
8409    {
8410        self.alter_user_password_hash_with_epoch_inner(
8411            username,
8412            hash,
8413            None,
8414            Some(&mut before_publish),
8415        )
8416    }
8417
8418    fn alter_user_password_hash_with_epoch_inner(
8419        &self,
8420        username: &str,
8421        hash: String,
8422        verifiers: Option<(
8423            crate::security_hardening::ScramVerifier,
8424            crate::auth::MysqlCachingSha2Verifier,
8425        )>,
8426        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8427    ) -> Result<Epoch> {
8428        let command = match verifiers {
8429            Some((scram_sha_256, mysql_caching_sha2)) => {
8430                crate::catalog_cmds::CatalogCommand::AlterUserPasswordWithAuthVerifiers {
8431                    username: username.to_string(),
8432                    password_hash: hash,
8433                    scram_sha_256,
8434                    mysql_caching_sha2,
8435                }
8436            }
8437            None => crate::catalog_cmds::CatalogCommand::AlterUserPassword {
8438                username: username.to_string(),
8439                password_hash: hash,
8440            },
8441        };
8442        self.require(&crate::catalog_cmds::required_permission(&command))?;
8443        let _ddl = self.ddl_lock.lock();
8444        let _security_write = self.security_write()?;
8445        self.require(&crate::catalog_cmds::required_permission(&command))?;
8446        let _commit = self.commit_lock.lock();
8447        let epoch = self.epoch.bump_assigned();
8448        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8449        let mut next_catalog = self.catalog.read().clone();
8450        self.apply_catalog_command_to(&mut next_catalog, command)?;
8451        next_catalog.db_epoch = epoch.0;
8452        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8453        Ok(epoch)
8454    }
8455
8456    /// Verify credentials. Returns `Some(entry)` on success, `None` on
8457    /// mismatch, `Err` on engine error.
8458    pub fn verify_user(
8459        &self,
8460        username: &str,
8461        password: &str,
8462    ) -> Result<Option<crate::auth::UserEntry>> {
8463        let cat = self.catalog.read();
8464        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
8465            return Ok(None);
8466        };
8467        if user.password_hash.is_empty() {
8468            return Ok(None);
8469        }
8470        let ok = crate::auth::verify_password(password, &user.password_hash)
8471            .map_err(MongrelError::Other)?;
8472        if ok {
8473            Ok(Some(user.clone()))
8474        } else {
8475            Ok(None)
8476        }
8477    }
8478
8479    /// Authenticate and resolve one immutable principal from the same catalog
8480    /// snapshot. Username reuse cannot bridge the password check and principal
8481    /// resolution.
8482    pub fn authenticate_principal(
8483        &self,
8484        username: &str,
8485        password: &str,
8486    ) -> Result<Option<crate::auth::Principal>> {
8487        self.authenticate_principal_inner(username, password, || {})
8488    }
8489
8490    fn authenticate_principal_inner<F>(
8491        &self,
8492        username: &str,
8493        password: &str,
8494        after_verify: F,
8495    ) -> Result<Option<crate::auth::Principal>>
8496    where
8497        F: FnOnce(),
8498    {
8499        let catalog = self.catalog.read();
8500        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
8501            return Ok(None);
8502        };
8503        if user.password_hash.is_empty()
8504            || !crate::auth::verify_password(password, &user.password_hash)
8505                .map_err(MongrelError::Other)?
8506        {
8507            return Ok(None);
8508        }
8509        after_verify();
8510        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
8511    }
8512
8513    /// Verify a MySQL 8 `caching_sha2_password` proof and resolve the same
8514    /// live catalog principal used by native SCRAM authentication.
8515    pub fn authenticate_mysql_caching_sha2_principal(
8516        &self,
8517        username: &str,
8518        nonce: &[u8],
8519        proof: &[u8],
8520    ) -> Option<crate::auth::Principal> {
8521        let catalog = self.catalog.read();
8522        let user = catalog
8523            .users
8524            .iter()
8525            .find(|user| user.username == username)?;
8526        if !user.mysql_caching_sha2.as_ref()?.verify(nonce, proof) {
8527            return None;
8528        }
8529        Self::resolve_user_principal_from_catalog(&catalog, user)
8530    }
8531
8532    /// Grant admin privileges to a user (bypasses all permission checks).
8533    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
8534        self.set_user_admin_with_epoch(username, is_admin)
8535            .map(|_| ())
8536    }
8537
8538    pub fn set_user_admin_with_epoch(
8539        &self,
8540        username: &str,
8541        is_admin: bool,
8542    ) -> Result<Option<Epoch>> {
8543        self.set_user_admin_with_epoch_inner(username, is_admin, None)
8544    }
8545
8546    pub fn set_user_admin_with_epoch_controlled<F>(
8547        &self,
8548        username: &str,
8549        is_admin: bool,
8550        mut before_publish: F,
8551    ) -> Result<Option<Epoch>>
8552    where
8553        F: FnMut() -> Result<()>,
8554    {
8555        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
8556    }
8557
8558    fn set_user_admin_with_epoch_inner(
8559        &self,
8560        username: &str,
8561        is_admin: bool,
8562        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8563    ) -> Result<Option<Epoch>> {
8564        let command = crate::catalog_cmds::CatalogCommand::SetUserAdmin {
8565            username: username.to_string(),
8566            is_admin,
8567        };
8568        self.require(&crate::catalog_cmds::required_permission(&command))?;
8569        let _ddl = self.ddl_lock.lock();
8570        let _security_write = self.security_write()?;
8571        self.require(&crate::catalog_cmds::required_permission(&command))?;
8572        let _commit = self.commit_lock.lock();
8573        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8574        // short-circuit), so validation runs pure first and only real changes
8575        // are applied and recorded.
8576        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8577        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8578            return Ok(None);
8579        }
8580        let epoch = self.epoch.bump_assigned();
8581        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8582        let mut next_catalog = self.catalog.read().clone();
8583        self.apply_catalog_command_to(&mut next_catalog, command)?;
8584        next_catalog.db_epoch = epoch.0;
8585        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8586        Ok(Some(epoch))
8587    }
8588
8589    /// Create a new role.
8590    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
8591        self.create_role_inner(name, None)
8592    }
8593
8594    pub fn create_role_controlled<F>(
8595        &self,
8596        name: &str,
8597        mut before_publish: F,
8598    ) -> Result<crate::auth::RoleEntry>
8599    where
8600        F: FnMut() -> Result<()>,
8601    {
8602        self.create_role_inner(name, Some(&mut before_publish))
8603    }
8604
8605    fn create_role_inner(
8606        &self,
8607        name: &str,
8608        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8609    ) -> Result<crate::auth::RoleEntry> {
8610        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8611            name: name.to_string(),
8612            created_epoch: 0, // stamped after the commit epoch is assigned
8613        };
8614        self.require(&crate::catalog_cmds::required_permission(&command))?;
8615        let _ddl = self.ddl_lock.lock();
8616        let _security_write = self.security_write()?;
8617        self.require(&crate::catalog_cmds::required_permission(&command))?;
8618        let _commit = self.commit_lock.lock();
8619        let epoch = self.epoch.bump_assigned();
8620        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8621        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8622            name: name.to_string(),
8623            created_epoch: epoch.0,
8624        };
8625        let mut next_catalog = self.catalog.read().clone();
8626        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8627        let crate::catalog_cmds::CatalogDelta::RoleUpserted(entry) = delta else {
8628            return Err(MongrelError::Other(
8629                "CreateRole resolved to an unexpected catalog delta".into(),
8630            ));
8631        };
8632        next_catalog.db_epoch = epoch.0;
8633        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8634        Ok(entry)
8635    }
8636
8637    /// Drop a role by name.
8638    pub fn drop_role(&self, name: &str) -> Result<()> {
8639        self.drop_role_with_epoch(name).map(|_| ())
8640    }
8641
8642    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
8643        self.drop_role_with_epoch_inner(name, None)
8644    }
8645
8646    pub fn drop_role_with_epoch_controlled<F>(
8647        &self,
8648        name: &str,
8649        mut before_publish: F,
8650    ) -> Result<Epoch>
8651    where
8652        F: FnMut() -> Result<()>,
8653    {
8654        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
8655    }
8656
8657    fn drop_role_with_epoch_inner(
8658        &self,
8659        name: &str,
8660        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8661    ) -> Result<Epoch> {
8662        let command = crate::catalog_cmds::CatalogCommand::DropRole {
8663            name: name.to_string(),
8664        };
8665        self.require(&crate::catalog_cmds::required_permission(&command))?;
8666        let _ddl = self.ddl_lock.lock();
8667        let _security_write = self.security_write()?;
8668        self.require(&crate::catalog_cmds::required_permission(&command))?;
8669        let _commit = self.commit_lock.lock();
8670        let epoch = self.epoch.bump_assigned();
8671        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8672        let mut next_catalog = self.catalog.read().clone();
8673        self.apply_catalog_command_to(&mut next_catalog, command)?;
8674        next_catalog.db_epoch = epoch.0;
8675        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8676        Ok(epoch)
8677    }
8678
8679    /// Grant a role to a user.
8680    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
8681        self.grant_role_with_epoch(username, role_name).map(|_| ())
8682    }
8683
8684    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8685        self.grant_role_with_epoch_inner(username, role_name, None)
8686    }
8687
8688    pub fn grant_role_with_epoch_controlled<F>(
8689        &self,
8690        username: &str,
8691        role_name: &str,
8692        mut before_publish: F,
8693    ) -> Result<Option<Epoch>>
8694    where
8695        F: FnMut() -> Result<()>,
8696    {
8697        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8698    }
8699
8700    fn grant_role_with_epoch_inner(
8701        &self,
8702        username: &str,
8703        role_name: &str,
8704        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8705    ) -> Result<Option<Epoch>> {
8706        let command = crate::catalog_cmds::CatalogCommand::GrantRole {
8707            username: username.to_string(),
8708            role: role_name.to_string(),
8709        };
8710        self.require(&crate::catalog_cmds::required_permission(&command))?;
8711        let _ddl = self.ddl_lock.lock();
8712        let _security_write = self.security_write()?;
8713        self.require(&crate::catalog_cmds::required_permission(&command))?;
8714        let _commit = self.commit_lock.lock();
8715        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8716        // short-circuit), so validation runs pure first and only real changes
8717        // are applied and recorded.
8718        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8719        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8720            return Ok(None);
8721        }
8722        let epoch = self.epoch.bump_assigned();
8723        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8724        let mut next_catalog = self.catalog.read().clone();
8725        self.apply_catalog_command_to(&mut next_catalog, command)?;
8726        next_catalog.db_epoch = epoch.0;
8727        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8728        Ok(Some(epoch))
8729    }
8730
8731    /// Revoke a role from a user.
8732    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
8733        self.revoke_role_with_epoch(username, role_name).map(|_| ())
8734    }
8735
8736    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8737        self.revoke_role_with_epoch_inner(username, role_name, None)
8738    }
8739
8740    pub fn revoke_role_with_epoch_controlled<F>(
8741        &self,
8742        username: &str,
8743        role_name: &str,
8744        mut before_publish: F,
8745    ) -> Result<Option<Epoch>>
8746    where
8747        F: FnMut() -> Result<()>,
8748    {
8749        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8750    }
8751
8752    fn revoke_role_with_epoch_inner(
8753        &self,
8754        username: &str,
8755        role_name: &str,
8756        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8757    ) -> Result<Option<Epoch>> {
8758        let command = crate::catalog_cmds::CatalogCommand::RevokeRole {
8759            username: username.to_string(),
8760            role: role_name.to_string(),
8761        };
8762        self.require(&crate::catalog_cmds::required_permission(&command))?;
8763        let _ddl = self.ddl_lock.lock();
8764        let _security_write = self.security_write()?;
8765        self.require(&crate::catalog_cmds::required_permission(&command))?;
8766        let _commit = self.commit_lock.lock();
8767        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8768        // short-circuit), so validation runs pure first and only real changes
8769        // are applied and recorded.
8770        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8771        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8772            return Ok(None);
8773        }
8774        let epoch = self.epoch.bump_assigned();
8775        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8776        let mut next_catalog = self.catalog.read().clone();
8777        self.apply_catalog_command_to(&mut next_catalog, command)?;
8778        next_catalog.db_epoch = epoch.0;
8779        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8780        Ok(Some(epoch))
8781    }
8782
8783    /// Grant a permission to a role.
8784    pub fn grant_permission(
8785        &self,
8786        role_name: &str,
8787        permission: crate::auth::Permission,
8788    ) -> Result<()> {
8789        self.grant_permission_with_epoch(role_name, permission)
8790            .map(|_| ())
8791    }
8792
8793    pub fn grant_permission_with_epoch(
8794        &self,
8795        role_name: &str,
8796        permission: crate::auth::Permission,
8797    ) -> Result<Option<Epoch>> {
8798        self.grant_permission_with_epoch_inner(role_name, permission, None)
8799    }
8800
8801    pub fn grant_permission_with_epoch_controlled<F>(
8802        &self,
8803        role_name: &str,
8804        permission: crate::auth::Permission,
8805        mut before_publish: F,
8806    ) -> Result<Option<Epoch>>
8807    where
8808        F: FnMut() -> Result<()>,
8809    {
8810        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8811    }
8812
8813    fn grant_permission_with_epoch_inner(
8814        &self,
8815        role_name: &str,
8816        permission: crate::auth::Permission,
8817        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8818    ) -> Result<Option<Epoch>> {
8819        let command = crate::catalog_cmds::CatalogCommand::GrantPermission {
8820            role: role_name.to_string(),
8821            permission,
8822        };
8823        self.require(&crate::catalog_cmds::required_permission(&command))?;
8824        let _ddl = self.ddl_lock.lock();
8825        let _security_write = self.security_write()?;
8826        self.require(&crate::catalog_cmds::required_permission(&command))?;
8827        let _commit = self.commit_lock.lock();
8828        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8829        // short-circuit), so validation runs pure first and only real changes
8830        // are applied and recorded.
8831        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8832        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8833            return Ok(None);
8834        }
8835        let epoch = self.epoch.bump_assigned();
8836        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8837        let mut next_catalog = self.catalog.read().clone();
8838        self.apply_catalog_command_to(&mut next_catalog, command)?;
8839        next_catalog.db_epoch = epoch.0;
8840        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8841        Ok(Some(epoch))
8842    }
8843
8844    /// Revoke a permission from a role.
8845    pub fn revoke_permission(
8846        &self,
8847        role_name: &str,
8848        permission: crate::auth::Permission,
8849    ) -> Result<()> {
8850        self.revoke_permission_with_epoch(role_name, permission)
8851            .map(|_| ())
8852    }
8853
8854    pub fn revoke_permission_with_epoch(
8855        &self,
8856        role_name: &str,
8857        permission: crate::auth::Permission,
8858    ) -> Result<Option<Epoch>> {
8859        self.revoke_permission_with_epoch_inner(role_name, permission, None)
8860    }
8861
8862    pub fn revoke_permission_with_epoch_controlled<F>(
8863        &self,
8864        role_name: &str,
8865        permission: crate::auth::Permission,
8866        mut before_publish: F,
8867    ) -> Result<Option<Epoch>>
8868    where
8869        F: FnMut() -> Result<()>,
8870    {
8871        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8872    }
8873
8874    fn revoke_permission_with_epoch_inner(
8875        &self,
8876        role_name: &str,
8877        permission: crate::auth::Permission,
8878        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8879    ) -> Result<Option<Epoch>> {
8880        let command = crate::catalog_cmds::CatalogCommand::RevokePermission {
8881            role: role_name.to_string(),
8882            permission,
8883        };
8884        self.require(&crate::catalog_cmds::required_permission(&command))?;
8885        let _ddl = self.ddl_lock.lock();
8886        let _security_write = self.security_write()?;
8887        self.require(&crate::catalog_cmds::required_permission(&command))?;
8888        let _commit = self.commit_lock.lock();
8889        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8890        // short-circuit), so validation runs pure first and only real changes
8891        // are applied and recorded.
8892        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8893        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8894            return Ok(None);
8895        }
8896        let epoch = self.epoch.bump_assigned();
8897        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8898        let mut next_catalog = self.catalog.read().clone();
8899        self.apply_catalog_command_to(&mut next_catalog, command)?;
8900        next_catalog.db_epoch = epoch.0;
8901        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8902        Ok(Some(epoch))
8903    }
8904
8905    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
8906    /// permissions from their roles. Returns `None` if the user doesn't exist.
8907    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
8908        let cat = self.catalog.read();
8909        Self::resolve_principal_from_catalog(&cat, username)
8910    }
8911
8912    /// Re-resolve only when the immutable user identity still exists. This is
8913    /// the server/session validation path; username reuse never matches.
8914    pub fn resolve_current_principal(
8915        &self,
8916        principal: &crate::auth::Principal,
8917    ) -> Option<crate::auth::Principal> {
8918        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
8919    }
8920
8921    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
8922    /// without needing a constructed `Database`. Used by the credentialed open
8923    /// path (which must verify credentials before the `Database` exists) and
8924    /// by [`resolve_principal`](Self::resolve_principal).
8925    fn resolve_principal_from_catalog(
8926        cat: &Catalog,
8927        username: &str,
8928    ) -> Option<crate::auth::Principal> {
8929        let user = cat.users.iter().find(|u| u.username == username)?;
8930        Self::resolve_user_principal_from_catalog(cat, user)
8931    }
8932
8933    fn resolve_bound_principal_from_catalog(
8934        cat: &Catalog,
8935        principal: &crate::auth::Principal,
8936    ) -> Option<crate::auth::Principal> {
8937        let user = cat.users.iter().find(|user| {
8938            user.id == principal.user_id
8939                && user.created_epoch == principal.created_epoch
8940                && user.username == principal.username
8941        })?;
8942        Self::resolve_user_principal_from_catalog(cat, user)
8943    }
8944
8945    fn resolve_user_principal_from_catalog(
8946        cat: &Catalog,
8947        user: &crate::auth::UserEntry,
8948    ) -> Option<crate::auth::Principal> {
8949        let mut permissions = Vec::new();
8950        for role_name in &user.roles {
8951            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
8952                permissions.extend(role.permissions.iter().cloned());
8953            }
8954        }
8955        Some(crate::auth::Principal {
8956            user_id: user.id,
8957            created_epoch: user.created_epoch,
8958            username: user.username.clone(),
8959            is_admin: user.is_admin,
8960            roles: user.roles.clone(),
8961            permissions,
8962        })
8963    }
8964
8965    /// Check whether a user has a specific permission (via their roles).
8966    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
8967        match self.resolve_principal(username) {
8968            Some(p) => p.has_permission(permission),
8969            None => false,
8970        }
8971    }
8972
8973    /// Returns `true` if this database's catalog has `require_auth = true`.
8974    /// When true, every operation consults the cached [`Principal`] via
8975    /// [`require`](Self::require).
8976    pub fn require_auth_enabled(&self) -> bool {
8977        self.catalog.read().require_auth
8978    }
8979
8980    /// A snapshot of the cached principal for this handle, if any. `None` for
8981    /// databases opened without credentials (the default). Returns a clone
8982    /// because the principal lives behind an `RwLock`.
8983    pub fn principal(&self) -> Option<crate::auth::Principal> {
8984        self.principal.read().clone()
8985    }
8986
8987    /// Build a `TableAuthChecker` from the current auth state. Used when
8988    /// mounting a new table (`create_table`) so the table inherits the
8989    /// database's enforcement configuration. The checker reads the live
8990    /// `require_auth` flag and cached principal, so changes via `enable_auth`
8991    /// / `refresh_principal` propagate to already-mounted tables.
8992    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
8993        if self.shared {
8994            return None;
8995        }
8996        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
8997            self.auth_state.clone(),
8998        )))
8999    }
9000
9001    /// Re-resolve the cached principal from the shared current catalog.
9002    /// Long-lived
9003    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
9004    /// possibly made by a different handle to the same database — to pick up
9005    /// the new effective permissions without re-verifying the password.
9006    ///
9007    /// The process-wide security version reloads from disk only when another
9008    /// handle published a newer catalog. The username is taken from
9009    /// the existing cached principal; if the user has since been dropped,
9010    /// returns [`MongrelError::InvalidCredentials`].
9011    ///
9012    /// No-op (returns `Ok(())`) on a credentialless database, or on a
9013    /// credentialed database whose cached principal is `None`.
9014    pub fn refresh_principal(&self) -> Result<()> {
9015        let previous = match self.principal.read().clone() {
9016            Some(principal) => principal,
9017            None => return Ok(()),
9018        };
9019        // Service principals are runtime identities, not catalog users. Their
9020        // immutable scopes are stored on the handle and never re-resolved by
9021        // username.
9022        if previous.user_id == 0 {
9023            return Ok(());
9024        }
9025        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
9026        self.refresh_security_catalog_if_stale(observed_version)?;
9027        let cat = self.catalog.read();
9028        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
9029            Some(p) => {
9030                *self.principal.write() = Some(p.clone());
9031                // Update the shared auth state so mounted Tables see the new
9032                // permissions immediately (Tables read from AuthState, not from
9033                // self.principal).
9034                self.auth_state.set_principal(Some(p));
9035                Ok(())
9036            }
9037            None => Err(MongrelError::InvalidCredentials {
9038                username: previous.username,
9039            }),
9040        }
9041    }
9042
9043    /// Number of security-catalog disk reloads performed by this open handle.
9044    /// Initial open reads are excluded.
9045    pub fn security_catalog_disk_read_count(&self) -> u64 {
9046        self.security_catalog_disk_reads.load(Ordering::Relaxed)
9047    }
9048
9049    /// Convert a credentialless database to a credentialed one: create the
9050    /// first admin user, set `require_auth = true`, and cache the admin
9051    /// principal on this handle so subsequent operations on the same handle
9052    /// continue to work. After this call, the database can only be reopened
9053    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
9054    ///
9055    /// Refuses if the database already has `require_auth = true`. This is
9056    /// the conversion path for existing databases; for fresh databases,
9057    /// `create_with_credentials` sets everything up atomically.
9058    ///
9059    /// See `docs/15-credential-enforcement.md`.
9060    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
9061        if self.shared {
9062            // Fail closed (spec §4.6): one shared handle must not flip the
9063            // core into an enforcement mode the other handles cannot observe.
9064            // Stage 1A shared cores stay credentialless; auth-mode transitions
9065            // require an exclusive `Database` owner. Per-handle principals
9066            // land with Stage 1D sessions.
9067            return Err(MongrelError::Conflict(
9068                "enable_auth requires an exclusive Database owner; shared cores reject \
9069                 auth-mode transitions"
9070                    .into(),
9071            ));
9072        }
9073        let password_hash =
9074            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
9075        let scram_sha_256 =
9076            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
9077        let mysql_caching_sha2 =
9078            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
9079        let _ddl = self.ddl_lock.lock();
9080        let _security_write = self.security_write()?;
9081        let _commit = self.commit_lock.lock();
9082        let epoch = self.epoch.bump_assigned();
9083        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9084        let mut next_catalog = self.catalog.read().clone();
9085        if next_catalog.require_auth {
9086            return Err(MongrelError::InvalidArgument(
9087                "database already has require_auth enabled".into(),
9088            ));
9089        }
9090        if next_catalog
9091            .users
9092            .iter()
9093            .any(|u| u.username == admin_username)
9094        {
9095            return Err(MongrelError::InvalidArgument(format!(
9096                "user {admin_username:?} already exists"
9097            )));
9098        }
9099        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
9100        let id = next_catalog.next_user_id;
9101        next_catalog.next_user_id = id
9102            .checked_add(1)
9103            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
9104        next_catalog.users.push(crate::auth::UserEntry {
9105            id,
9106            username: admin_username.to_string(),
9107            password_hash,
9108            scram_sha_256: Some(scram_sha_256),
9109            mysql_caching_sha2: Some(mysql_caching_sha2),
9110            roles: Vec::new(),
9111            is_admin: true,
9112            created_epoch: epoch.0,
9113        });
9114        next_catalog.require_auth = true;
9115        advance_security_version(&mut next_catalog)?;
9116        next_catalog.db_epoch = epoch.0;
9117        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9118        // Cache the admin principal on this handle + update the shared auth
9119        // state whenever rename published, even if directory fsync was
9120        // inconclusive.
9121        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9122            let principal = crate::auth::Principal {
9123                user_id: id,
9124                created_epoch: epoch.0,
9125                username: admin_username.to_string(),
9126                is_admin: true,
9127                roles: Vec::new(),
9128                permissions: Vec::new(),
9129            };
9130            *self.principal.write() = Some(principal.clone());
9131            self.auth_state.set_principal(Some(principal));
9132        }
9133        publish
9134    }
9135
9136    /// Disable `require_auth` on this database, reverting it to credentialless
9137    /// mode. This is the **recovery** path — it requires the handle to already
9138    /// be open (and therefore already authenticated if `require_auth` was on).
9139    ///
9140    /// After this call, the database can be reopened with plain
9141    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
9142    /// credentials. All existing users and roles are preserved in the catalog
9143    /// (so `require_auth` can be re-enabled without recreating them), but they
9144    /// are no longer consulted for enforcement.
9145    ///
9146    /// For true **offline** recovery (when credentials are lost and no
9147    /// authenticated handle is available), the caller opens the database
9148    /// directly via the catalog file (filesystem access required) and calls
9149    /// this method — see the CLI's `auth disable-offline` command.
9150    ///
9151    /// See `docs/15-credential-enforcement.md` §4.7.
9152    pub fn disable_auth(&self) -> Result<()> {
9153        let _ddl = self.ddl_lock.lock();
9154        let _security_write = self.security_write()?;
9155        let _commit = self.commit_lock.lock();
9156        let epoch = self.epoch.bump_assigned();
9157        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9158        let mut next_catalog = self.catalog.read().clone();
9159        if !next_catalog.require_auth {
9160            return Err(MongrelError::InvalidArgument(
9161                "database does not have require_auth enabled".into(),
9162            ));
9163        }
9164        next_catalog.require_auth = false;
9165        advance_security_version(&mut next_catalog)?;
9166        next_catalog.db_epoch = epoch.0;
9167        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9168        // Clear the cached principal — enforcement is now off.
9169        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9170            *self.principal.write() = None;
9171        }
9172        publish
9173    }
9174
9175    /// Enforcement check: if the catalog has `require_auth = true`, verify
9176    /// that the cached principal satisfies `perm`. Called by every
9177    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
9178    /// Table/Transaction/MongrelSession operations).
9179    ///
9180    /// On a credentialless database this is a no-op (`Ok(())`).
9181    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
9182        self.ensure_owner_process()?;
9183        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
9184            return Err(MongrelError::ReadOnlyReplica);
9185        }
9186        if self.principal.read().is_some() {
9187            self.refresh_principal().map_err(|error| match error {
9188                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
9189                error => error,
9190            })?;
9191        }
9192        if !self.catalog.read().require_auth {
9193            return Ok(());
9194        }
9195        let guard = self.principal.read();
9196        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
9197        if p.has_permission(perm) {
9198            Ok(())
9199        } else {
9200            Err(MongrelError::PermissionDenied {
9201                required: perm.clone(),
9202                principal: p.username.clone(),
9203            })
9204        }
9205    }
9206
9207    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
9208    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
9209    /// other callers that know the operation kind + table name but don't want
9210    /// to construct the full `Permission` enum value themselves.
9211    pub fn require_table(
9212        &self,
9213        table: &str,
9214        perm: crate::auth_state::RequiredPermission,
9215    ) -> Result<()> {
9216        self.require(&perm.into_permission(table))
9217    }
9218
9219    pub fn triggers(&self) -> Vec<StoredTrigger> {
9220        self.catalog
9221            .read()
9222            .triggers
9223            .iter()
9224            .map(|t| t.trigger.clone())
9225            .collect()
9226    }
9227
9228    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
9229        self.catalog
9230            .read()
9231            .triggers
9232            .iter()
9233            .find(|t| t.trigger.name == name)
9234            .map(|t| t.trigger.clone())
9235    }
9236
9237    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9238        self.create_trigger_inner(trigger, None, None)
9239    }
9240
9241    pub fn create_trigger_controlled<F>(
9242        &self,
9243        trigger: StoredTrigger,
9244        mut before_publish: F,
9245    ) -> Result<StoredTrigger>
9246    where
9247        F: FnMut() -> Result<()>,
9248    {
9249        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
9250    }
9251
9252    pub fn create_trigger_as_controlled<F>(
9253        &self,
9254        trigger: StoredTrigger,
9255        principal: Option<&crate::auth::Principal>,
9256        mut before_publish: F,
9257    ) -> Result<StoredTrigger>
9258    where
9259        F: FnMut() -> Result<()>,
9260    {
9261        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
9262    }
9263
9264    fn create_trigger_inner(
9265        &self,
9266        mut trigger: StoredTrigger,
9267        principal: Option<&crate::auth::Principal>,
9268        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9269    ) -> Result<StoredTrigger> {
9270        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9271            trigger: trigger.clone(),
9272        };
9273        self.require_for(
9274            principal,
9275            &crate::catalog_cmds::required_permission(&command),
9276        )?;
9277        let _g = self.ddl_lock.lock();
9278        let _security_write = self.security_write()?;
9279        self.require_for(
9280            principal,
9281            &crate::catalog_cmds::required_permission(&command),
9282        )?;
9283        trigger.validate()?;
9284        self.validate_trigger_references(&trigger)
9285            .map_err(trigger_validation_error)?;
9286        // S1F-001: validation runs pure against the current catalog first so
9287        // failures (duplicate name, unknown target) surface before an epoch is
9288        // consumed, exactly like the legacy pre-bump checks.
9289        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9290        let commit_lock = Arc::clone(&self.commit_lock);
9291        let _c = commit_lock.lock();
9292        let epoch = self.epoch.bump_assigned();
9293        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9294        trigger.created_epoch = epoch.0;
9295        trigger.updated_epoch = epoch.0;
9296        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9297            trigger: trigger.clone(),
9298        };
9299        let mut next_catalog = self.catalog.read().clone();
9300        self.apply_catalog_command_to(&mut next_catalog, command)?;
9301        next_catalog.db_epoch = epoch.0;
9302        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9303        Ok(trigger)
9304    }
9305
9306    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9307        self.create_or_replace_trigger_inner(trigger, None, None)
9308    }
9309
9310    pub fn create_or_replace_trigger_controlled<F>(
9311        &self,
9312        trigger: StoredTrigger,
9313        mut before_publish: F,
9314    ) -> Result<StoredTrigger>
9315    where
9316        F: FnMut() -> Result<()>,
9317    {
9318        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
9319    }
9320
9321    pub fn create_or_replace_trigger_as_controlled<F>(
9322        &self,
9323        trigger: StoredTrigger,
9324        principal: Option<&crate::auth::Principal>,
9325        mut before_publish: F,
9326    ) -> Result<StoredTrigger>
9327    where
9328        F: FnMut() -> Result<()>,
9329    {
9330        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
9331    }
9332
9333    fn create_or_replace_trigger_inner(
9334        &self,
9335        trigger: StoredTrigger,
9336        principal: Option<&crate::auth::Principal>,
9337        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9338    ) -> Result<StoredTrigger> {
9339        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9340            trigger: trigger.clone(),
9341        };
9342        self.require_for(
9343            principal,
9344            &crate::catalog_cmds::required_permission(&command),
9345        )?;
9346        let _g = self.ddl_lock.lock();
9347        let _security_write = self.security_write()?;
9348        self.require_for(
9349            principal,
9350            &crate::catalog_cmds::required_permission(&command),
9351        )?;
9352        trigger.validate()?;
9353        self.validate_trigger_references(&trigger)
9354            .map_err(trigger_validation_error)?;
9355        // S1F-001: validation runs pure against the current catalog first so
9356        // structural failures surface before an epoch is consumed, exactly
9357        // like the legacy pre-bump checks.
9358        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9359        let commit_lock = Arc::clone(&self.commit_lock);
9360        let _c = commit_lock.lock();
9361        let epoch = self.epoch.bump_assigned();
9362        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9363        let mut next_catalog = self.catalog.read().clone();
9364        // Resolve the replacement image against the candidate catalog: the
9365        // engine stamps version/epochs from the commit epoch (preserving the
9366        // original created_epoch on replacement), then the command record
9367        // carries that resolved image.
9368        let replaced = match next_catalog
9369            .triggers
9370            .iter()
9371            .position(|t| t.trigger.name == trigger.name)
9372        {
9373            Some(idx) => next_catalog.triggers[idx]
9374                .trigger
9375                .replaced(trigger.clone(), epoch.0)?,
9376            None => {
9377                let mut next = trigger;
9378                next.created_epoch = epoch.0;
9379                next.updated_epoch = epoch.0;
9380                next
9381            }
9382        };
9383        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9384            trigger: replaced.clone(),
9385        };
9386        self.apply_catalog_command_to(&mut next_catalog, command)?;
9387        next_catalog.db_epoch = epoch.0;
9388        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9389        Ok(replaced)
9390    }
9391
9392    pub fn drop_trigger(&self, name: &str) -> Result<()> {
9393        self.drop_trigger_with_epoch(name).map(|_| ())
9394    }
9395
9396    /// Drop one trigger and return the exact catalog publication epoch.
9397    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
9398        self.drop_triggers_with_epoch(&[name.to_string()])
9399    }
9400
9401    pub fn drop_trigger_with_epoch_controlled<F>(
9402        &self,
9403        name: &str,
9404        before_publish: F,
9405    ) -> Result<Epoch>
9406    where
9407        F: FnMut() -> Result<()>,
9408    {
9409        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
9410    }
9411
9412    /// Atomically drop several triggers in one catalog publication.
9413    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
9414        self.drop_triggers_with_epoch_inner(names, None, None)
9415    }
9416
9417    pub fn drop_triggers_with_epoch_controlled<F>(
9418        &self,
9419        names: &[String],
9420        mut before_publish: F,
9421    ) -> Result<Epoch>
9422    where
9423        F: FnMut() -> Result<()>,
9424    {
9425        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
9426    }
9427
9428    pub fn drop_triggers_with_epoch_as_controlled<F>(
9429        &self,
9430        names: &[String],
9431        principal: Option<&crate::auth::Principal>,
9432        mut before_publish: F,
9433    ) -> Result<Epoch>
9434    where
9435        F: FnMut() -> Result<()>,
9436    {
9437        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
9438    }
9439
9440    fn drop_triggers_with_epoch_inner(
9441        &self,
9442        names: &[String],
9443        principal: Option<&crate::auth::Principal>,
9444        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9445    ) -> Result<Epoch> {
9446        if names.is_empty() {
9447            return Err(MongrelError::InvalidArgument(
9448                "at least one trigger name is required".into(),
9449            ));
9450        }
9451        let commands: Vec<crate::catalog_cmds::CatalogCommand> = names
9452            .iter()
9453            .map(|name| crate::catalog_cmds::CatalogCommand::DropTrigger { name: name.clone() })
9454            .collect();
9455        for command in &commands {
9456            self.require_for(
9457                principal,
9458                &crate::catalog_cmds::required_permission(command),
9459            )?;
9460        }
9461        let _g = self.ddl_lock.lock();
9462        let _security_write = self.security_write()?;
9463        for command in &commands {
9464            self.require_for(
9465                principal,
9466                &crate::catalog_cmds::required_permission(command),
9467            )?;
9468        }
9469        // S1F-001: every name's command validates pure against the current
9470        // catalog first so a missing trigger surfaces before an epoch is
9471        // consumed, exactly like the legacy pre-bump check. A batch drop then
9472        // records one command per name (the emitting-layer expansion rule).
9473        {
9474            let cat = self.catalog.read();
9475            for command in &commands {
9476                crate::catalog_cmds::apply(&cat, command)?;
9477            }
9478        }
9479        let commit_lock = Arc::clone(&self.commit_lock);
9480        let _c = commit_lock.lock();
9481        let epoch = self.epoch.bump_assigned();
9482        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9483        let mut next_catalog = self.catalog.read().clone();
9484        for command in commands {
9485            self.apply_catalog_command_to(&mut next_catalog, command)?;
9486        }
9487        next_catalog.db_epoch = epoch.0;
9488        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9489        Ok(epoch)
9490    }
9491
9492    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
9493        self.catalog.read().external_tables.clone()
9494    }
9495
9496    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
9497        self.catalog
9498            .read()
9499            .external_tables
9500            .iter()
9501            .find(|t| t.name == name)
9502            .cloned()
9503    }
9504
9505    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
9506        self.create_external_table_inner(entry, None)
9507    }
9508
9509    pub fn create_external_table_controlled<F>(
9510        &self,
9511        entry: ExternalTableEntry,
9512        mut before_publish: F,
9513    ) -> Result<ExternalTableEntry>
9514    where
9515        F: FnMut() -> Result<()>,
9516    {
9517        self.create_external_table_inner(entry, Some(&mut before_publish))
9518    }
9519
9520    fn create_external_table_inner(
9521        &self,
9522        mut entry: ExternalTableEntry,
9523        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9524    ) -> Result<ExternalTableEntry> {
9525        self.require(&crate::auth::Permission::Ddl)?;
9526        let _g = self.ddl_lock.lock();
9527        let _security_write = self.security_write()?;
9528        self.require(&crate::auth::Permission::Ddl)?;
9529        entry.validate()?;
9530        {
9531            let cat = self.catalog.read();
9532            if cat.live(&entry.name).is_some()
9533                || cat.external_tables.iter().any(|t| t.name == entry.name)
9534            {
9535                return Err(MongrelError::InvalidArgument(format!(
9536                    "table {:?} already exists",
9537                    entry.name
9538                )));
9539            }
9540        }
9541        let commit_lock = Arc::clone(&self.commit_lock);
9542        let _c = commit_lock.lock();
9543        // A prior durable drop may have left connector state behind if its
9544        // cleanup failed or the process crashed. Never let a new table with
9545        // the same name inherit that stale state.
9546        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
9547        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
9548        let epoch = self.epoch.bump_assigned();
9549        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9550        entry.created_epoch = epoch.0;
9551        let mut next_catalog = self.catalog.read().clone();
9552        next_catalog.external_tables.push(entry.clone());
9553        next_catalog.db_epoch = epoch.0;
9554        self.publish_catalog_candidate_with_prelude(
9555            next_catalog,
9556            epoch,
9557            &mut _epoch_guard,
9558            before_publish,
9559            vec![(
9560                EXTERNAL_TABLE_ID,
9561                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9562                    name: entry.name.clone(),
9563                    generation_epoch: epoch.0,
9564                }),
9565            )],
9566        )?;
9567        Ok(entry)
9568    }
9569
9570    pub fn drop_external_table(&self, name: &str) -> Result<()> {
9571        self.drop_external_table_with_epoch(name).map(|_| ())
9572    }
9573
9574    /// Drop an external table and return the exact catalog publication epoch.
9575    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
9576        self.drop_external_table_with_epoch_inner(name, None)
9577    }
9578
9579    pub fn drop_external_table_with_epoch_controlled<F>(
9580        &self,
9581        name: &str,
9582        mut before_publish: F,
9583    ) -> Result<Epoch>
9584    where
9585        F: FnMut() -> Result<()>,
9586    {
9587        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
9588    }
9589
9590    fn drop_external_table_with_epoch_inner(
9591        &self,
9592        name: &str,
9593        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9594    ) -> Result<Epoch> {
9595        self.require(&crate::auth::Permission::Ddl)?;
9596        let _g = self.ddl_lock.lock();
9597        let _security_write = self.security_write()?;
9598        self.require(&crate::auth::Permission::Ddl)?;
9599        let commit_lock = Arc::clone(&self.commit_lock);
9600        let _c = commit_lock.lock();
9601        let epoch = self.epoch.bump_assigned();
9602        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9603        let mut next_catalog = self.catalog.read().clone();
9604        let before = next_catalog.external_tables.len();
9605        next_catalog.external_tables.retain(|t| t.name != name);
9606        if next_catalog.external_tables.len() == before {
9607            return Err(MongrelError::NotFound(format!(
9608                "external table {name:?} not found"
9609            )));
9610        }
9611        next_catalog.db_epoch = epoch.0;
9612        self.publish_catalog_candidate_with_prelude(
9613            next_catalog,
9614            epoch,
9615            &mut _epoch_guard,
9616            before_publish,
9617            vec![(
9618                EXTERNAL_TABLE_ID,
9619                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9620                    name: name.to_string(),
9621                    generation_epoch: epoch.0,
9622                }),
9623            )],
9624        )?;
9625        let state_dir = self.root.join(VTAB_DIR).join(name);
9626        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
9627            return Err(MongrelError::DurableCommit {
9628                epoch: epoch.0,
9629                message: format!(
9630                    "external table was dropped but connector-state cleanup failed: {error}"
9631                ),
9632            });
9633        }
9634        Ok(epoch)
9635    }
9636
9637    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
9638        let txn_id = self.alloc_txn_id()?;
9639        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9640        self.commit_transaction_with_external_states(
9641            txn_id,
9642            self.epoch.visible(),
9643            Vec::new(),
9644            vec![(name.to_string(), state.to_vec())],
9645            Vec::new(),
9646            principal,
9647            catalog_bound,
9648            None,
9649            crate::txn::TxnCommitContext::internal(),
9650        )
9651        .map(|(epoch, _)| epoch)
9652    }
9653
9654    pub fn trigger_config(&self) -> TriggerConfig {
9655        use std::sync::atomic::Ordering;
9656        TriggerConfig {
9657            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
9658            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
9659            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
9660        }
9661    }
9662
9663    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
9664        use std::sync::atomic::Ordering;
9665        if config.max_depth == 0 {
9666            return Err(MongrelError::InvalidArgument(
9667                "trigger max_depth must be greater than 0".into(),
9668            ));
9669        }
9670        self.trigger_recursive
9671            .store(config.recursive_triggers, Ordering::Relaxed);
9672        self.trigger_max_depth
9673            .store(config.max_depth, Ordering::Relaxed);
9674        self.trigger_max_loop_iterations
9675            .store(config.max_loop_iterations, Ordering::Relaxed);
9676        Ok(())
9677    }
9678
9679    pub fn set_recursive_triggers(&self, recursive: bool) {
9680        use std::sync::atomic::Ordering;
9681        self.trigger_recursive.store(recursive, Ordering::Relaxed);
9682    }
9683
9684    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
9685    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
9686    /// as a low-latency wake-up.
9687    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
9688        self.notify.subscribe()
9689    }
9690
9691    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
9692        self.change_wake.subscribe()
9693    }
9694
9695    /// Reconstruct committed row changes from the retained shared WAL. Event
9696    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
9697    /// resumes before the oldest retained commit receives `gap = true` and
9698    /// must rebootstrap instead of silently skipping changes.
9699    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
9700        let control = crate::ExecutionControl::new(None);
9701        self.change_events_since_controlled(last_event_id, &control)
9702    }
9703
9704    /// Reconstruct committed changes with cooperative cancellation and bounds.
9705    pub fn change_events_since_controlled(
9706        &self,
9707        last_event_id: Option<&str>,
9708        control: &crate::ExecutionControl,
9709    ) -> Result<CdcBatch> {
9710        use crate::wal::Op;
9711
9712        control.checkpoint()?;
9713        let resume = match last_event_id {
9714            Some(id) => {
9715                let (epoch, index) = id.split_once(':').ok_or_else(|| {
9716                    MongrelError::InvalidArgument(format!(
9717                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
9718                    ))
9719                })?;
9720                Some((
9721                    epoch.parse::<u64>().map_err(|error| {
9722                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
9723                    })?,
9724                    index.parse::<u32>().map_err(|error| {
9725                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
9726                    })?,
9727                ))
9728            }
9729            None => None,
9730        };
9731
9732        let mut wal = self.shared_wal.lock();
9733        wal.group_sync()?;
9734        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
9735        let records = crate::wal::SharedWal::replay_with_dek_controlled(
9736            &self.root,
9737            wal_dek.as_ref(),
9738            control,
9739            CDC_MAX_WAL_RECORDS,
9740            CDC_MAX_WAL_REPLAY_BYTES,
9741        )?;
9742        drop(wal);
9743        control.checkpoint()?;
9744
9745        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
9746        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
9747        for (index, record) in records.iter().enumerate() {
9748            if index % 256 == 0 {
9749                control.checkpoint()?;
9750            }
9751            if let Op::TxnCommit { epoch, added_runs } = &record.op {
9752                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
9753            }
9754            if let Op::SpilledRows { table_id, rows } = &record.op {
9755                spilled_payloads
9756                    .entry((record.txn_id, *table_id))
9757                    .or_default()
9758                    .push(rows);
9759            }
9760        }
9761        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
9762        let current_epoch = self.epoch.committed().0;
9763        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
9764        let gap = resume.is_some_and(|(epoch, _)| {
9765            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
9766        });
9767        if gap {
9768            return Ok(CdcBatch {
9769                events: Vec::new(),
9770                current_epoch,
9771                earliest_epoch,
9772                gap: true,
9773            });
9774        }
9775
9776        let table_names: HashMap<u64, String> = self
9777            .catalog
9778            .read()
9779            .tables
9780            .iter()
9781            .map(|entry| (entry.table_id, entry.name.clone()))
9782            .collect();
9783        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
9784        let mut retained_bytes = 0_usize;
9785        for (index, record) in records.iter().enumerate() {
9786            if index % 256 == 0 {
9787                control.checkpoint()?;
9788            }
9789            if !commits.contains_key(&record.txn_id) {
9790                continue;
9791            }
9792            let Op::BeforeImage {
9793                table_id,
9794                row_id,
9795                row,
9796            } = &record.op
9797            else {
9798                continue;
9799            };
9800            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9801                return Err(MongrelError::ResourceLimitExceeded {
9802                    resource: "CDC before-image bytes",
9803                    requested: row.len(),
9804                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9805                });
9806            }
9807            let before: crate::memtable::Row = bincode::deserialize(row)?;
9808            if before_images.len() >= CDC_MAX_ROWS {
9809                return Err(MongrelError::ResourceLimitExceeded {
9810                    resource: "CDC before-image rows",
9811                    requested: before_images.len().saturating_add(1),
9812                    limit: CDC_MAX_ROWS,
9813                });
9814            }
9815            charge_cdc_bytes(
9816                &mut retained_bytes,
9817                cdc_row_storage_bytes(&before),
9818                "CDC retained bytes",
9819            )?;
9820            before_images.insert((record.txn_id, *table_id, row_id.0), before);
9821        }
9822        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
9823        let mut events = Vec::new();
9824        let mut decoded_rows = before_images.len();
9825        for (record_index, record) in records.iter().enumerate() {
9826            if record_index % 256 == 0 {
9827                control.checkpoint()?;
9828            }
9829            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
9830                continue;
9831            };
9832            let event = match &record.op {
9833                Op::Put { table_id, rows } => {
9834                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9835                        return Err(MongrelError::ResourceLimitExceeded {
9836                            resource: "CDC inline row bytes",
9837                            requested: rows.len(),
9838                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9839                        });
9840                    }
9841                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
9842                    decoded_rows = decoded_rows.saturating_add(rows.len());
9843                    if decoded_rows > CDC_MAX_ROWS {
9844                        return Err(MongrelError::ResourceLimitExceeded {
9845                            resource: "CDC decoded rows",
9846                            requested: decoded_rows,
9847                            limit: CDC_MAX_ROWS,
9848                        });
9849                    }
9850                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
9851                    let mut peak_bytes = retained_bytes;
9852                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9853                    let data = serde_json::to_value(rows)
9854                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
9855                    Some((*table_id, "put", data, event_bytes))
9856                }
9857                Op::Delete { table_id, row_ids } => {
9858                    let before = row_ids
9859                        .iter()
9860                        .filter_map(|row_id| {
9861                            before_images
9862                                .get(&(record.txn_id, *table_id, row_id.0))
9863                                .cloned()
9864                        })
9865                        .collect::<Vec<_>>();
9866                    let event_bytes = cdc_rows_json_bytes(&before)
9867                        .saturating_add(
9868                            row_ids
9869                                .len()
9870                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
9871                        )
9872                        .saturating_add(512);
9873                    let mut peak_bytes = retained_bytes;
9874                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9875                    Some((
9876                        *table_id,
9877                        "delete",
9878                        serde_json::json!({
9879                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
9880                            "before": before,
9881                        }),
9882                        event_bytes,
9883                    ))
9884                }
9885                Op::TruncateTable { table_id } => {
9886                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
9887                }
9888                _ => None,
9889            };
9890            if let Some((table_id, op, data, event_bytes)) = event {
9891                let index = operation_indices.entry(record.txn_id).or_insert(0);
9892                let event_position = (*commit_epoch, *index);
9893                *index = index.saturating_add(1);
9894                if resume.is_some_and(|position| event_position <= position) {
9895                    continue;
9896                }
9897                if events.len() >= CDC_MAX_EVENTS {
9898                    return Err(MongrelError::ResourceLimitExceeded {
9899                        resource: "CDC events",
9900                        requested: events.len().saturating_add(1),
9901                        limit: CDC_MAX_EVENTS,
9902                    });
9903                }
9904                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
9905                events.push(ChangeEvent {
9906                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
9907                    channel: "changes".into(),
9908                    table_id: Some(table_id),
9909                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
9910                    op: op.into(),
9911                    epoch: *commit_epoch,
9912                    txn_id: Some(record.txn_id),
9913                    message: None,
9914                    data: Some(data),
9915                });
9916            }
9917            if let Op::TxnCommit { added_runs, .. } = &record.op {
9918                for run in added_runs {
9919                    control.checkpoint()?;
9920                    let index = operation_indices.entry(record.txn_id).or_insert(0);
9921                    let event_position = (*commit_epoch, *index);
9922                    *index = index.saturating_add(1);
9923                    if resume.is_some_and(|position| event_position <= position) {
9924                        continue;
9925                    }
9926                    let mut rows = if let Some(payloads) =
9927                        spilled_payloads.get(&(record.txn_id, run.table_id))
9928                    {
9929                        let mut rows = Vec::new();
9930                        for payload in payloads {
9931                            control.checkpoint()?;
9932                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9933                                return Err(MongrelError::ResourceLimitExceeded {
9934                                    resource: "CDC spilled row bytes",
9935                                    requested: payload.len(),
9936                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9937                                });
9938                            }
9939                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
9940                            if decoded_rows
9941                                .saturating_add(rows.len())
9942                                .saturating_add(chunk.len())
9943                                > CDC_MAX_ROWS
9944                            {
9945                                return Err(MongrelError::ResourceLimitExceeded {
9946                                    resource: "CDC decoded rows",
9947                                    requested: decoded_rows
9948                                        .saturating_add(rows.len())
9949                                        .saturating_add(chunk.len()),
9950                                    limit: CDC_MAX_ROWS,
9951                                });
9952                            }
9953                            rows.extend(chunk);
9954                        }
9955                        rows
9956                    } else {
9957                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
9958                            return Ok(CdcBatch {
9959                                events: Vec::new(),
9960                                current_epoch,
9961                                earliest_epoch,
9962                                gap: true,
9963                            });
9964                        };
9965                        let table = handle.lock();
9966                        let mut reader = match table.open_reader(run.run_id) {
9967                            Ok(reader) => reader,
9968                            Err(_) => {
9969                                return Ok(CdcBatch {
9970                                    events: Vec::new(),
9971                                    current_epoch,
9972                                    earliest_epoch,
9973                                    gap: true,
9974                                })
9975                            }
9976                        };
9977                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
9978                        let rows = reader.all_rows_controlled(control, remaining)?;
9979                        drop(reader);
9980                        drop(table);
9981                        rows
9982                    };
9983                    for row in &mut rows {
9984                        row.committed_epoch = Epoch(*commit_epoch);
9985                    }
9986                    decoded_rows = decoded_rows.saturating_add(rows.len());
9987                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
9988                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
9989                    if events.len() >= CDC_MAX_EVENTS {
9990                        return Err(MongrelError::ResourceLimitExceeded {
9991                            resource: "CDC events",
9992                            requested: events.len().saturating_add(1),
9993                            limit: CDC_MAX_EVENTS,
9994                        });
9995                    }
9996                    events.push(ChangeEvent {
9997                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
9998                        channel: "changes".into(),
9999                        table_id: Some(run.table_id),
10000                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
10001                        op: "put_run".into(),
10002                        epoch: *commit_epoch,
10003                        txn_id: Some(record.txn_id),
10004                        message: None,
10005                        data: Some(serde_json::json!({
10006                            "run_id": run.run_id.to_string(),
10007                            "row_count": run.row_count,
10008                            "min_row_id": run.min_row_id,
10009                            "max_row_id": run.max_row_id,
10010                            "rows": rows,
10011                        })),
10012                    });
10013                }
10014            }
10015        }
10016        control.checkpoint()?;
10017        Ok(CdcBatch {
10018            events,
10019            current_epoch,
10020            earliest_epoch,
10021            gap: false,
10022        })
10023    }
10024
10025    /// Publish a notification message on a named channel. Reaches all active
10026    /// subscribers (daemon `/events`, application listeners).
10027    pub fn notify(&self, channel: &str, message: Option<String>) {
10028        let _ = self.notify.send(ChangeEvent {
10029            id: None,
10030            channel: channel.to_string(),
10031            table_id: None,
10032            table: String::new(),
10033            op: "notify".into(),
10034            epoch: self.epoch.visible().0,
10035            txn_id: None,
10036            message,
10037            data: None,
10038        });
10039    }
10040
10041    pub fn call_procedure(
10042        &self,
10043        name: &str,
10044        args: HashMap<String, crate::Value>,
10045    ) -> Result<ProcedureCallResult> {
10046        self.call_procedure_as(name, args, None)
10047    }
10048
10049    pub fn call_procedure_as(
10050        &self,
10051        name: &str,
10052        args: HashMap<String, crate::Value>,
10053        principal: Option<&crate::auth::Principal>,
10054    ) -> Result<ProcedureCallResult> {
10055        let control = crate::ExecutionControl::new(None);
10056        self.call_procedure_as_controlled(name, args, principal, &control, || true)
10057    }
10058
10059    /// Execute only the exact procedure revision previously authorized by the
10060    /// caller. A dropped or replaced definition fails closed.
10061    #[doc(hidden)]
10062    pub fn call_procedure_as_bound(
10063        &self,
10064        expected: &StoredProcedure,
10065        args: HashMap<String, crate::Value>,
10066        principal: Option<&crate::auth::Principal>,
10067    ) -> Result<ProcedureCallResult> {
10068        self.require_for(principal, &crate::auth::Permission::All)?;
10069        let procedure = self.procedure(&expected.name).ok_or_else(|| {
10070            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
10071        })?;
10072        if &procedure != expected {
10073            return Err(MongrelError::Conflict(format!(
10074                "procedure {:?} changed after request authorization",
10075                expected.name
10076            )));
10077        }
10078        let control = crate::ExecutionControl::new(None);
10079        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
10080    }
10081
10082    /// Execute a procedure with cooperative cancellation during preparation.
10083    /// `before_commit` runs after every procedure step has succeeded and
10084    /// immediately before a write procedure commits. Returning `false` aborts
10085    /// the transaction without publishing it.
10086    #[doc(hidden)]
10087    pub fn call_procedure_as_controlled<F>(
10088        &self,
10089        name: &str,
10090        args: HashMap<String, crate::Value>,
10091        principal: Option<&crate::auth::Principal>,
10092        control: &crate::ExecutionControl,
10093        before_commit: F,
10094    ) -> Result<ProcedureCallResult>
10095    where
10096        F: FnOnce() -> bool,
10097    {
10098        // v1 requires ALL to call procedures on a require_auth database; a
10099        // finer SECURITY DEFINER-style marker is a future extension (spec §9
10100        // decision 1).
10101        self.require_for(principal, &crate::auth::Permission::All)?;
10102        let procedure = self
10103            .procedure(name)
10104            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
10105        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
10106    }
10107
10108    fn execute_procedure_as_controlled<F>(
10109        &self,
10110        procedure: StoredProcedure,
10111        args: HashMap<String, crate::Value>,
10112        principal: Option<&crate::auth::Principal>,
10113        control: &crate::ExecutionControl,
10114        before_commit: F,
10115    ) -> Result<ProcedureCallResult>
10116    where
10117        F: FnOnce() -> bool,
10118    {
10119        let args = bind_procedure_args(&procedure, args)?;
10120        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
10121        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
10122        if has_writes {
10123            let mut tx = self.begin_as(principal.cloned());
10124            let run = (|| {
10125                for (step_index, step) in procedure.body.steps.iter().enumerate() {
10126                    if step_index % 256 == 0 {
10127                        control.checkpoint()?;
10128                    }
10129                    let output = self.execute_procedure_step(
10130                        step,
10131                        &args,
10132                        &outputs,
10133                        Some(&mut tx),
10134                        principal,
10135                        Some(control),
10136                    )?;
10137                    outputs.insert(step.id().to_string(), output);
10138                }
10139                control.checkpoint()?;
10140                eval_return_output(&procedure.body.return_value, &args, &outputs)
10141            })();
10142            match run {
10143                Ok(output) => {
10144                    control.checkpoint()?;
10145                    if !before_commit() {
10146                        tx.rollback();
10147                        return Err(MongrelError::Cancelled);
10148                    }
10149                    let epoch = tx.commit()?.0;
10150                    Ok(ProcedureCallResult {
10151                        epoch: Some(epoch),
10152                        output,
10153                    })
10154                }
10155                Err(e) => {
10156                    tx.rollback();
10157                    Err(e)
10158                }
10159            }
10160        } else {
10161            for (step_index, step) in procedure.body.steps.iter().enumerate() {
10162                if step_index % 256 == 0 {
10163                    control.checkpoint()?;
10164                }
10165                let output = self.execute_procedure_step(
10166                    step,
10167                    &args,
10168                    &outputs,
10169                    None,
10170                    principal,
10171                    Some(control),
10172                )?;
10173                outputs.insert(step.id().to_string(), output);
10174            }
10175            control.checkpoint()?;
10176            Ok(ProcedureCallResult {
10177                epoch: None,
10178                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
10179            })
10180        }
10181    }
10182
10183    fn execute_procedure_step(
10184        &self,
10185        step: &ProcedureStep,
10186        args: &HashMap<String, crate::Value>,
10187        outputs: &HashMap<String, ProcedureCallOutput>,
10188        tx: Option<&mut crate::txn::Transaction<'_>>,
10189        principal: Option<&crate::auth::Principal>,
10190        control: Option<&crate::ExecutionControl>,
10191    ) -> Result<ProcedureCallOutput> {
10192        if let Some(control) = control {
10193            control.checkpoint()?;
10194        }
10195        match step {
10196            ProcedureStep::NativeQuery {
10197                table,
10198                conditions,
10199                projection,
10200                limit,
10201                ..
10202            } => {
10203                let mut q = crate::Query::new();
10204                for condition in conditions {
10205                    q = q.and(eval_condition(condition, args, outputs)?);
10206                }
10207                let fallback_control = crate::ExecutionControl::new(None);
10208                let query_control = control.unwrap_or(&fallback_control);
10209                let mut rows = self.query_for_principal_controlled(
10210                    table,
10211                    &q,
10212                    projection.as_deref(),
10213                    principal,
10214                    false,
10215                    query_control,
10216                )?;
10217                if let Some(limit) = limit {
10218                    rows.truncate(*limit);
10219                }
10220                let mut output = Vec::with_capacity(rows.len());
10221                for (row_index, row) in rows.into_iter().enumerate() {
10222                    if row_index % 256 == 0 {
10223                        if let Some(control) = control {
10224                            control.checkpoint()?;
10225                        }
10226                    }
10227                    output.push(ProcedureCallRow {
10228                        row_id: Some(row.row_id),
10229                        columns: row.columns,
10230                    });
10231                }
10232                Ok(ProcedureCallOutput::Rows(output))
10233            }
10234            ProcedureStep::Put {
10235                table,
10236                cells,
10237                returning,
10238                ..
10239            } => {
10240                let tx = tx.ok_or_else(|| {
10241                    MongrelError::InvalidArgument(
10242                        "write procedure step requires a transaction".into(),
10243                    )
10244                })?;
10245                let cells = eval_cells(cells, args, outputs)?;
10246                if *returning {
10247                    let out = tx.put_returning(table, cells)?;
10248                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10249                        row_id: None,
10250                        columns: out.row.columns.into_iter().collect(),
10251                    }))
10252                } else {
10253                    tx.put(table, cells)?;
10254                    Ok(ProcedureCallOutput::Null)
10255                }
10256            }
10257            ProcedureStep::Upsert {
10258                table,
10259                cells,
10260                update_cells,
10261                returning,
10262                ..
10263            } => {
10264                let tx = tx.ok_or_else(|| {
10265                    MongrelError::InvalidArgument(
10266                        "write procedure step requires a transaction".into(),
10267                    )
10268                })?;
10269                let cells = eval_cells(cells, args, outputs)?;
10270                let action = match update_cells {
10271                    Some(update_cells) => {
10272                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
10273                    }
10274                    None => crate::UpsertAction::DoNothing,
10275                };
10276                let out = tx.upsert(table, cells, action)?;
10277                if *returning {
10278                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10279                        row_id: None,
10280                        columns: out.row.columns.into_iter().collect(),
10281                    }))
10282                } else {
10283                    Ok(ProcedureCallOutput::Null)
10284                }
10285            }
10286            ProcedureStep::DeleteByPk { table, pk, .. } => {
10287                let tx = tx.ok_or_else(|| {
10288                    MongrelError::InvalidArgument(
10289                        "write procedure step requires a transaction".into(),
10290                    )
10291                })?;
10292                let pk = eval_value(pk, args, outputs)?;
10293                let handle = self.table(table)?;
10294                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
10295                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
10296                })?;
10297                tx.delete(table, row_id)?;
10298                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
10299            }
10300            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
10301                "DeleteRows procedure step is not supported by the core executor yet".into(),
10302            )),
10303            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
10304                "SqlQuery procedure step must be executed by mongreldb-query".into(),
10305            )),
10306        }
10307    }
10308
10309    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
10310        let cat = self.catalog.read();
10311        for step in &procedure.body.steps {
10312            let Some(table_name) = step.table() else {
10313                continue;
10314            };
10315            let schema = &cat
10316                .live(table_name)
10317                .ok_or_else(|| {
10318                    MongrelError::InvalidArgument(format!(
10319                        "procedure {:?} references unknown table {table_name:?}",
10320                        procedure.name
10321                    ))
10322                })?
10323                .schema;
10324            match step {
10325                ProcedureStep::NativeQuery {
10326                    conditions,
10327                    projection,
10328                    ..
10329                } => {
10330                    for condition in conditions {
10331                        validate_condition_columns(condition, schema)?;
10332                    }
10333                    if let Some(projection) = projection {
10334                        for id in projection {
10335                            validate_column_id(*id, schema)?;
10336                        }
10337                    }
10338                }
10339                ProcedureStep::Put { cells, .. } => {
10340                    for cell in cells {
10341                        validate_column_id(cell.column_id, schema)?;
10342                    }
10343                }
10344                ProcedureStep::Upsert {
10345                    cells,
10346                    update_cells,
10347                    ..
10348                } => {
10349                    for cell in cells {
10350                        validate_column_id(cell.column_id, schema)?;
10351                    }
10352                    if let Some(update_cells) = update_cells {
10353                        for cell in update_cells {
10354                            validate_column_id(cell.column_id, schema)?;
10355                        }
10356                    }
10357                }
10358                ProcedureStep::DeleteByPk { .. } => {
10359                    if schema.primary_key().is_none() {
10360                        return Err(MongrelError::InvalidArgument(format!(
10361                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
10362                            procedure.name
10363                        )));
10364                    }
10365                }
10366                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
10367            }
10368        }
10369        Ok(())
10370    }
10371
10372    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
10373        let cat = self.catalog.read();
10374        let target_schema = match &trigger.target {
10375            TriggerTarget::Table(target_name) => cat
10376                .live(target_name)
10377                .ok_or_else(|| {
10378                    MongrelError::InvalidArgument(format!(
10379                        "trigger {:?} references unknown target table {target_name:?}",
10380                        trigger.name
10381                    ))
10382                })?
10383                .schema
10384                .clone(),
10385            TriggerTarget::View(_) => Schema {
10386                columns: trigger.target_columns.clone(),
10387                ..Schema::default()
10388            },
10389        };
10390        for col in &trigger.update_of {
10391            if target_schema.column(col).is_none() {
10392                return Err(MongrelError::InvalidArgument(format!(
10393                    "trigger {:?} UPDATE OF references unknown column {col:?}",
10394                    trigger.name
10395                )));
10396            }
10397        }
10398        if let Some(expr) = &trigger.when {
10399            validate_trigger_expr(expr, &target_schema, trigger.event)?;
10400        }
10401        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
10402        for step in &trigger.program.steps {
10403            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
10404            {
10405                return Err(MongrelError::InvalidArgument(
10406                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
10407                ));
10408            }
10409            validate_trigger_step(
10410                step,
10411                &cat,
10412                &target_schema,
10413                trigger.event,
10414                &mut select_schemas,
10415            )?;
10416        }
10417        Ok(())
10418    }
10419
10420    /// Begin a new transaction reading at the current visible epoch.
10421    pub fn begin(&self) -> crate::txn::Transaction<'_> {
10422        self.begin_with_isolation(crate::txn::IsolationLevel::default())
10423    }
10424
10425    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
10426        let principal = self.principal.read().clone();
10427        let catalog_bound = principal
10428            .as_ref()
10429            .is_some_and(|principal| principal.user_id != 0);
10430        (principal, catalog_bound)
10431    }
10432
10433    pub fn begin_as(
10434        &self,
10435        principal: Option<crate::auth::Principal>,
10436    ) -> crate::txn::Transaction<'_> {
10437        let catalog_bound = principal
10438            .as_ref()
10439            .is_some_and(|principal| principal.user_id != 0);
10440        let txn_id = self.alloc_txn_id();
10441        let read = self.visible_snapshot();
10442        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10443            .with_principal(principal, catalog_bound)
10444    }
10445
10446    /// Begin a transaction with a specific isolation level.
10447    pub fn begin_with_isolation(
10448        &self,
10449        level: crate::txn::IsolationLevel,
10450    ) -> crate::txn::Transaction<'_> {
10451        let txn_id = self.alloc_txn_id();
10452        // Every level pins the current visible epoch + HLC at begin; ReadCommitted
10453        // re-pins per statement inside the transaction (S1B-002 / P0.5).
10454        let read = self.visible_snapshot();
10455        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10456        crate::txn::Transaction::new(self, txn_id, read, level)
10457            .with_principal(principal, catalog_bound)
10458    }
10459
10460    /// Begin a transaction whose trigger programs may route external-table DML
10461    /// through an application/query-layer module bridge.
10462    pub fn begin_with_external_trigger_bridge<'a>(
10463        &'a self,
10464        bridge: &'a dyn ExternalTriggerBridge,
10465    ) -> crate::txn::Transaction<'a> {
10466        let txn_id = self.alloc_txn_id();
10467        let read = self.visible_snapshot();
10468        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10469        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10470            .with_external_trigger_bridge(bridge)
10471            .with_principal(principal, catalog_bound)
10472    }
10473
10474    pub fn begin_with_external_trigger_bridge_as<'a>(
10475        &'a self,
10476        bridge: &'a dyn ExternalTriggerBridge,
10477        principal: Option<crate::auth::Principal>,
10478    ) -> crate::txn::Transaction<'a> {
10479        let catalog_bound = principal
10480            .as_ref()
10481            .is_some_and(|principal| principal.user_id != 0);
10482        let txn_id = self.alloc_txn_id();
10483        let read = self.visible_snapshot();
10484        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10485            .with_external_trigger_bridge(bridge)
10486            .with_principal(principal, catalog_bound)
10487    }
10488
10489    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
10490    pub fn transaction<T>(
10491        &self,
10492        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10493    ) -> Result<T> {
10494        let mut tx = self.begin();
10495        match f(&mut tx) {
10496            Ok(out) => {
10497                tx.commit()?;
10498                Ok(out)
10499            }
10500            Err(e) => {
10501                tx.rollback();
10502                Err(e)
10503            }
10504        }
10505    }
10506
10507    pub fn transaction_with_row_ids<T>(
10508        &self,
10509        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10510    ) -> Result<(T, Vec<RowId>)> {
10511        let mut tx = self.begin();
10512        match f(&mut tx) {
10513            Ok(output) => {
10514                let (_, row_ids) = tx.commit_with_row_ids()?;
10515                Ok((output, row_ids))
10516            }
10517            Err(error) => {
10518                tx.rollback();
10519                Err(error)
10520            }
10521        }
10522    }
10523
10524    pub fn transaction_for_current_principal<T>(
10525        &self,
10526        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10527    ) -> Result<T> {
10528        if self.principal.read().is_some() {
10529            self.refresh_principal()?;
10530        }
10531        let mut transaction = self.begin_as(self.principal.read().clone());
10532        match f(&mut transaction) {
10533            Ok(output) => {
10534                transaction.commit()?;
10535                Ok(output)
10536            }
10537            Err(error) => {
10538                transaction.rollback();
10539                Err(error)
10540            }
10541        }
10542    }
10543
10544    pub fn transaction_for_current_principal_with_epoch<T>(
10545        &self,
10546        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10547    ) -> Result<(Epoch, T)> {
10548        if self.principal.read().is_some() {
10549            self.refresh_principal()?;
10550        }
10551        let mut transaction = self.begin_as(self.principal.read().clone());
10552        match f(&mut transaction) {
10553            Ok(output) => {
10554                let epoch = transaction.commit()?;
10555                Ok((epoch, output))
10556            }
10557            Err(error) => {
10558                transaction.rollback();
10559                Err(error)
10560            }
10561        }
10562    }
10563
10564    pub fn transaction_with_row_ids_for_current_principal<T>(
10565        &self,
10566        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10567    ) -> Result<(T, Vec<RowId>)> {
10568        if self.principal.read().is_some() {
10569            self.refresh_principal()?;
10570        }
10571        let mut transaction = self.begin_as(self.principal.read().clone());
10572        match f(&mut transaction) {
10573            Ok(output) => {
10574                let (_, row_ids) = transaction.commit_with_row_ids()?;
10575                Ok((output, row_ids))
10576            }
10577            Err(error) => {
10578                transaction.rollback();
10579                Err(error)
10580            }
10581        }
10582    }
10583
10584    /// Run `f` in a transaction with an external-trigger bridge; commit on
10585    /// `Ok`, rollback on `Err`.
10586    pub fn transaction_with_external_trigger_bridge<'a, T>(
10587        &'a self,
10588        bridge: &'a dyn ExternalTriggerBridge,
10589        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10590    ) -> Result<T> {
10591        let mut tx = self.begin_with_external_trigger_bridge(bridge);
10592        match f(&mut tx) {
10593            Ok(out) => {
10594                tx.commit()?;
10595                Ok(out)
10596            }
10597            Err(e) => {
10598                tx.rollback();
10599                Err(e)
10600            }
10601        }
10602    }
10603
10604    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
10605        &'a self,
10606        bridge: &'a dyn ExternalTriggerBridge,
10607        principal: Option<crate::auth::Principal>,
10608        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10609    ) -> Result<T> {
10610        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
10611        match f(&mut tx) {
10612            Ok(output) => {
10613                tx.commit()?;
10614                Ok(output)
10615            }
10616            Err(error) => {
10617                tx.rollback();
10618                Err(error)
10619            }
10620        }
10621    }
10622
10623    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
10624    /// `Transaction::new` so registration happens **before** any read.
10625    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
10626        self.active_txns.register(epoch)
10627    }
10628
10629    fn fill_auto_increment_for_staging(
10630        &self,
10631        txn_id: u64,
10632        staging: &mut [(u64, crate::txn::Staged)],
10633        control: Option<&crate::ExecutionControl>,
10634    ) -> Result<()> {
10635        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
10636        for (index, (table_id, staged)) in staging.iter().enumerate() {
10637            commit_prepare_checkpoint(control, index)?;
10638            if matches!(staged, crate::txn::Staged::Put(_)) {
10639                puts_by_table.entry(*table_id).or_default().push(index);
10640            }
10641        }
10642
10643        // S1B-003: sequence allocation serializes on one Exclusive barrier per
10644        // staged table whose puts actually ALLOCATE an auto-increment value
10645        // (absent/Null column — explicit values only advance the counter and
10646        // must not serialize), held until the transaction ends so allocated
10647        // values map monotonically onto commit order. The barrier is acquired
10648        // before the table lock (never the reverse).
10649        {
10650            let mut barrier_tables: Vec<u64> = puts_by_table
10651                .iter()
10652                .filter(|(table_id, indexes)| {
10653                    indexes.iter().any(|index| {
10654                        matches!(
10655                            &staging[*index].1,
10656                            crate::txn::Staged::Put(cells)
10657                                if self.table_auto_inc_would_allocate(**table_id, cells)
10658                        )
10659                    })
10660                })
10661                .map(|(table_id, _)| *table_id)
10662                .collect();
10663            barrier_tables.sort_unstable();
10664            for table_id in barrier_tables {
10665                self.acquire_txn_lock(
10666                    txn_id,
10667                    crate::locks::LockKey::sequence_barrier(&format!("auto_inc:{table_id}")),
10668                    crate::locks::LockMode::Exclusive,
10669                    control,
10670                )?;
10671            }
10672        }
10673
10674        let tables = self.tables.read();
10675        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
10676            commit_prepare_checkpoint(control, table_index)?;
10677            if let Some(handle) = tables.get(&table_id) {
10678                #[cfg(test)]
10679                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10680                let mut t = handle.lock();
10681                for (fill_index, index) in indexes.into_iter().enumerate() {
10682                    commit_prepare_checkpoint(control, fill_index)?;
10683                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
10684                        t.fill_auto_inc(cells)?;
10685                    }
10686                }
10687            }
10688        }
10689        Ok(())
10690    }
10691
10692    fn expand_table_triggers(
10693        &self,
10694        txn_id: u64,
10695        staging: &mut Vec<(u64, crate::txn::Staged)>,
10696        read_epoch: Epoch,
10697        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
10698        external_states: &mut Vec<(String, Vec<u8>)>,
10699        control: Option<&crate::ExecutionControl>,
10700    ) -> Result<()> {
10701        commit_prepare_checkpoint(control, 0)?;
10702        let mut external_writes = Vec::new();
10703        let config = self.trigger_config();
10704        if config.recursive_triggers {
10705            let chunk = std::mem::take(staging);
10706            let stacks = vec![Vec::new(); chunk.len()];
10707            *staging = self.expand_trigger_chunk(
10708                txn_id,
10709                chunk,
10710                stacks,
10711                read_epoch,
10712                0,
10713                config.max_depth,
10714                &mut external_writes,
10715                &config,
10716                control,
10717            )?;
10718            self.apply_external_trigger_writes(
10719                external_writes,
10720                external_trigger_bridge,
10721                external_states,
10722                staging,
10723                control,
10724            )?;
10725            return Ok(());
10726        }
10727
10728        let mut expansion =
10729            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
10730        if !expansion.before.is_empty() {
10731            let mut final_staging = expansion.before;
10732            final_staging.extend(filter_ignored_staging(
10733                std::mem::take(staging),
10734                &expansion.ignored_indices,
10735            ));
10736            *staging = final_staging;
10737        } else if !expansion.ignored_indices.is_empty() {
10738            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
10739        }
10740        staging.append(&mut expansion.after);
10741        external_writes.append(&mut expansion.before_external);
10742        external_writes.append(&mut expansion.after_external);
10743        self.apply_external_trigger_writes(
10744            external_writes,
10745            external_trigger_bridge,
10746            external_states,
10747            staging,
10748            control,
10749        )?;
10750        Ok(())
10751    }
10752
10753    #[allow(clippy::too_many_arguments)]
10754    fn expand_trigger_chunk(
10755        &self,
10756        txn_id: u64,
10757        mut chunk: Vec<(u64, crate::txn::Staged)>,
10758        stacks: Vec<Vec<String>>,
10759        read_epoch: Epoch,
10760        depth: u32,
10761        max_depth: u32,
10762        external_writes: &mut Vec<ExternalTriggerWrite>,
10763        config: &TriggerConfig,
10764        control: Option<&crate::ExecutionControl>,
10765    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
10766        if chunk.is_empty() {
10767            return Ok(Vec::new());
10768        }
10769        commit_prepare_checkpoint(control, 0)?;
10770        self.fill_auto_increment_for_staging(txn_id, &mut chunk, control)?;
10771        let expansion = self.expand_table_triggers_once(
10772            &mut chunk,
10773            read_epoch,
10774            Some(&stacks),
10775            config,
10776            control,
10777        )?;
10778        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
10779            let stack = expansion
10780                .before_stacks
10781                .first()
10782                .or_else(|| expansion.after_stacks.first())
10783                .cloned()
10784                .unwrap_or_default();
10785            return Err(MongrelError::TriggerValidation(format!(
10786                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
10787                Self::format_trigger_stack(&stack)
10788            )));
10789        }
10790
10791        let mut out = Vec::new();
10792        external_writes.extend(expansion.before_external);
10793        out.extend(self.expand_trigger_chunk(
10794            txn_id,
10795            expansion.before,
10796            expansion.before_stacks,
10797            read_epoch,
10798            depth + 1,
10799            max_depth,
10800            external_writes,
10801            config,
10802            control,
10803        )?);
10804        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
10805        external_writes.extend(expansion.after_external);
10806        out.extend(self.expand_trigger_chunk(
10807            txn_id,
10808            expansion.after,
10809            expansion.after_stacks,
10810            read_epoch,
10811            depth + 1,
10812            max_depth,
10813            external_writes,
10814            config,
10815            control,
10816        )?);
10817        Ok(out)
10818    }
10819
10820    fn apply_external_trigger_writes(
10821        &self,
10822        writes: Vec<ExternalTriggerWrite>,
10823        bridge: Option<&dyn ExternalTriggerBridge>,
10824        external_states: &mut Vec<(String, Vec<u8>)>,
10825        staging: &mut Vec<(u64, crate::txn::Staged)>,
10826        control: Option<&crate::ExecutionControl>,
10827    ) -> Result<()> {
10828        if writes.is_empty() {
10829            return Ok(());
10830        }
10831        let bridge = bridge.ok_or_else(|| {
10832            MongrelError::TriggerValidation(
10833                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
10834            )
10835        })?;
10836        for (write_index, write) in writes.into_iter().enumerate() {
10837            commit_prepare_checkpoint(control, write_index)?;
10838            let table = write.table().to_string();
10839            let entry = self.external_table(&table).ok_or_else(|| {
10840                MongrelError::NotFound(format!("external table {table:?} not found"))
10841            })?;
10842            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
10843            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
10844            external_states.push((table, result.state));
10845            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
10846                commit_prepare_checkpoint(control, base_index)?;
10847                match base_write {
10848                    ExternalTriggerBaseWrite::Put { table, cells } => {
10849                        let table_id = self.table_id(&table)?;
10850                        staging.push((table_id, crate::txn::Staged::Put(cells)));
10851                    }
10852                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
10853                        let table_id = self.table_id(&table)?;
10854                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
10855                    }
10856                }
10857            }
10858        }
10859        dedup_external_states_in_place(external_states);
10860        Ok(())
10861    }
10862
10863    fn expand_table_triggers_once(
10864        &self,
10865        staging: &mut Vec<(u64, crate::txn::Staged)>,
10866        read_epoch: Epoch,
10867        trigger_stacks: Option<&[Vec<String>]>,
10868        config: &TriggerConfig,
10869        control: Option<&crate::ExecutionControl>,
10870    ) -> Result<TriggerExpansion> {
10871        commit_prepare_checkpoint(control, 0)?;
10872        let triggers: Vec<StoredTrigger> = self
10873            .catalog
10874            .read()
10875            .triggers
10876            .iter()
10877            .filter(|entry| {
10878                entry.trigger.enabled
10879                    && matches!(
10880                        entry.trigger.timing,
10881                        TriggerTiming::Before | TriggerTiming::After
10882                    )
10883                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
10884            })
10885            .map(|entry| entry.trigger.clone())
10886            .collect();
10887        if triggers.is_empty() || staging.is_empty() {
10888            return Ok(TriggerExpansion::default());
10889        }
10890
10891        let before_triggers = triggers
10892            .iter()
10893            .filter(|trigger| trigger.timing == TriggerTiming::Before)
10894            .cloned()
10895            .collect::<Vec<_>>();
10896        let after_triggers = triggers
10897            .iter()
10898            .filter(|trigger| trigger.timing == TriggerTiming::After)
10899            .cloned()
10900            .collect::<Vec<_>>();
10901
10902        let mut before_added = Vec::new();
10903        let mut before_stacks = Vec::new();
10904        let mut before_external = Vec::new();
10905        let mut ignored_indices = std::collections::BTreeSet::new();
10906        if !before_triggers.is_empty() {
10907            let before_events =
10908                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
10909            let mut out = TriggerProgramOutput {
10910                added: &mut before_added,
10911                added_stacks: &mut before_stacks,
10912                added_external: &mut before_external,
10913                ignored_indices: &mut ignored_indices,
10914            };
10915            self.execute_triggers_for_events(
10916                &before_triggers,
10917                &before_events,
10918                Some(staging),
10919                &mut out,
10920                config,
10921                read_epoch,
10922                control,
10923            )?;
10924        }
10925
10926        let after_events = if after_triggers.is_empty() {
10927            Vec::new()
10928        } else {
10929            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
10930                .into_iter()
10931                .filter(|event| {
10932                    !event
10933                        .op_indices
10934                        .iter()
10935                        .any(|idx| ignored_indices.contains(idx))
10936                })
10937                .collect()
10938        };
10939
10940        let mut after_added = Vec::new();
10941        let mut after_stacks = Vec::new();
10942        let mut after_external = Vec::new();
10943        let mut out = TriggerProgramOutput {
10944            added: &mut after_added,
10945            added_stacks: &mut after_stacks,
10946            added_external: &mut after_external,
10947            ignored_indices: &mut ignored_indices,
10948        };
10949        self.execute_triggers_for_events(
10950            &after_triggers,
10951            &after_events,
10952            None,
10953            &mut out,
10954            config,
10955            read_epoch,
10956            control,
10957        )?;
10958        Ok(TriggerExpansion {
10959            before: before_added,
10960            before_stacks,
10961            before_external,
10962            after: after_added,
10963            after_stacks,
10964            after_external,
10965            ignored_indices,
10966        })
10967    }
10968
10969    #[allow(clippy::too_many_arguments)]
10970    fn execute_triggers_for_events(
10971        &self,
10972        triggers: &[StoredTrigger],
10973        events: &[WriteEvent],
10974        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
10975        out: &mut TriggerProgramOutput<'_>,
10976        config: &TriggerConfig,
10977        read_epoch: Epoch,
10978        control: Option<&crate::ExecutionControl>,
10979    ) -> Result<()> {
10980        let mut checkpoint_index = 0_usize;
10981        for event in events {
10982            for trigger in triggers {
10983                commit_prepare_checkpoint(control, checkpoint_index)?;
10984                checkpoint_index += 1;
10985                if event
10986                    .op_indices
10987                    .iter()
10988                    .any(|idx| out.ignored_indices.contains(idx))
10989                {
10990                    break;
10991                }
10992                let matches = {
10993                    let cat = self.catalog.read();
10994                    trigger_matches_event(trigger, event, &cat)?
10995                };
10996                if !matches {
10997                    continue;
10998                }
10999                if let Some(when) = &trigger.when {
11000                    if !eval_trigger_expr(when, event)? {
11001                        continue;
11002                    }
11003                }
11004                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
11005                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
11006                    return Err(MongrelError::TriggerValidation(format!(
11007                        "trigger recursion cycle detected; trigger stack: {}",
11008                        Self::format_trigger_stack(&trigger_stack)
11009                    )));
11010                }
11011                let outcome = match staging.as_mut() {
11012                    Some(staging) => self.execute_trigger_program(
11013                        trigger,
11014                        event,
11015                        Some(&mut **staging),
11016                        out,
11017                        &trigger_stack,
11018                        config,
11019                        read_epoch,
11020                        control,
11021                    )?,
11022                    None => self.execute_trigger_program(
11023                        trigger,
11024                        event,
11025                        None,
11026                        out,
11027                        &trigger_stack,
11028                        config,
11029                        read_epoch,
11030                        control,
11031                    )?,
11032                };
11033                if outcome == TriggerProgramOutcome::Ignore {
11034                    out.ignored_indices.extend(event.op_indices.iter().copied());
11035                    break;
11036                }
11037            }
11038        }
11039        Ok(())
11040    }
11041
11042    fn trigger_events_for_staging(
11043        &self,
11044        staging: &[(u64, crate::txn::Staged)],
11045        read_epoch: Epoch,
11046        trigger_stacks: Option<&[Vec<String>]>,
11047        control: Option<&crate::ExecutionControl>,
11048    ) -> Result<Vec<WriteEvent>> {
11049        use crate::txn::Staged;
11050        use std::collections::{HashMap, VecDeque};
11051
11052        let snapshot = self.snapshot_for_epoch(read_epoch);
11053        let cat = self.catalog.read();
11054        let mut table_names = HashMap::new();
11055        let mut table_schemas = HashMap::new();
11056        for entry in cat
11057            .tables
11058            .iter()
11059            .filter(|entry| matches!(entry.state, TableState::Live))
11060        {
11061            table_names.insert(entry.table_id, entry.name.clone());
11062            table_schemas.insert(entry.table_id, entry.schema.clone());
11063        }
11064        drop(cat);
11065
11066        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
11067        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11068        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11069
11070        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11071            commit_prepare_checkpoint(control, idx)?;
11072            let Some(schema) = table_schemas.get(table_id) else {
11073                continue;
11074            };
11075            let Some(pk) = schema.primary_key() else {
11076                continue;
11077            };
11078            match staged {
11079                Staged::Delete(row_id) => {
11080                    let handle = self.table_by_id(*table_id)?;
11081                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
11082                        continue;
11083                    };
11084                    let Some(pk_value) = row.columns.get(&pk.id) else {
11085                        continue;
11086                    };
11087                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
11088                    delete_by_key
11089                        .entry((*table_id, pk_value.encode_key()))
11090                        .or_default()
11091                        .push_back(idx);
11092                }
11093                Staged::Put(cells) => {
11094                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
11095                        put_by_key
11096                            .entry((*table_id, value.encode_key()))
11097                            .or_default()
11098                            .push_back(idx);
11099                    }
11100                }
11101                Staged::Update { row_id, .. } => {
11102                    let handle = self.table_by_id(*table_id)?;
11103                    let row = handle.lock().get(*row_id, snapshot);
11104                    if let Some(row) = row {
11105                        old_rows.insert(idx, TriggerRowImage::from_row(row));
11106                    }
11107                }
11108                Staged::Truncate => {}
11109            }
11110        }
11111
11112        let mut paired_delete = std::collections::HashSet::new();
11113        let mut paired_put = std::collections::HashSet::new();
11114        let mut events = Vec::new();
11115
11116        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
11117            commit_prepare_checkpoint(control, pair_index)?;
11118            let Some(puts) = put_by_key.get_mut(key) else {
11119                continue;
11120            };
11121            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
11122                paired_delete.insert(delete_idx);
11123                paired_put.insert(put_idx);
11124                let (table_id, _) = &staging[put_idx];
11125                let Some(table_name) = table_names.get(table_id).cloned() else {
11126                    continue;
11127                };
11128                let old = old_rows.get(&delete_idx).cloned();
11129                let new = match &staging[put_idx].1 {
11130                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
11131                    _ => None,
11132                };
11133                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11134                events.push(WriteEvent {
11135                    table: table_name,
11136                    kind: TriggerEvent::Update,
11137                    old,
11138                    new,
11139                    changed_columns,
11140                    op_indices: vec![delete_idx, put_idx],
11141                    put_idx: Some(put_idx),
11142                    trigger_stack: Self::trigger_stack_for_indices(
11143                        trigger_stacks,
11144                        &[delete_idx, put_idx],
11145                    ),
11146                });
11147            }
11148        }
11149
11150        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11151            commit_prepare_checkpoint(control, idx)?;
11152            let Some(table_name) = table_names.get(table_id).cloned() else {
11153                continue;
11154            };
11155            match staged {
11156                Staged::Put(cells) if !paired_put.contains(&idx) => {
11157                    let new = Some(TriggerRowImage::from_cells(cells));
11158                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
11159                    events.push(WriteEvent {
11160                        table: table_name,
11161                        kind: TriggerEvent::Insert,
11162                        old: None,
11163                        new,
11164                        changed_columns,
11165                        op_indices: vec![idx],
11166                        put_idx: Some(idx),
11167                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11168                    });
11169                }
11170                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
11171                    let old = match old_rows.get(&idx).cloned() {
11172                        Some(old) => Some(old),
11173                        None => {
11174                            let handle = self.table_by_id(*table_id)?;
11175                            let row = handle.lock().get(*row_id, snapshot);
11176                            row.map(TriggerRowImage::from_row)
11177                        }
11178                    };
11179                    let Some(old) = old else {
11180                        continue;
11181                    };
11182                    let changed_columns = old.columns.keys().copied().collect();
11183                    events.push(WriteEvent {
11184                        table: table_name,
11185                        kind: TriggerEvent::Delete,
11186                        old: Some(old),
11187                        new: None,
11188                        changed_columns,
11189                        op_indices: vec![idx],
11190                        put_idx: None,
11191                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11192                    });
11193                }
11194                Staged::Update { new_row: cells, .. } => {
11195                    let old = old_rows.get(&idx).cloned();
11196                    let new = Some(TriggerRowImage::from_cells(cells));
11197                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11198                    events.push(WriteEvent {
11199                        table: table_name,
11200                        kind: TriggerEvent::Update,
11201                        old,
11202                        new,
11203                        changed_columns,
11204                        op_indices: vec![idx],
11205                        put_idx: Some(idx),
11206                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11207                    });
11208                }
11209                Staged::Truncate => {}
11210                _ => {}
11211            }
11212        }
11213
11214        Ok(events)
11215    }
11216
11217    #[allow(clippy::too_many_arguments)]
11218    fn execute_trigger_program(
11219        &self,
11220        trigger: &StoredTrigger,
11221        event: &WriteEvent,
11222        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11223        out: &mut TriggerProgramOutput<'_>,
11224        trigger_stack: &[String],
11225        config: &TriggerConfig,
11226        read_epoch: Epoch,
11227        control: Option<&crate::ExecutionControl>,
11228    ) -> Result<TriggerProgramOutcome> {
11229        let mut event = event.clone();
11230        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
11231        self.execute_trigger_steps(
11232            trigger,
11233            &trigger.program.steps,
11234            &mut event,
11235            staging,
11236            out,
11237            trigger_stack,
11238            config,
11239            &mut select_results,
11240            0,
11241            None,
11242            read_epoch,
11243            control,
11244        )
11245    }
11246
11247    #[allow(clippy::too_many_arguments)]
11248    fn execute_trigger_steps(
11249        &self,
11250        trigger: &StoredTrigger,
11251        steps: &[TriggerStep],
11252        event: &mut WriteEvent,
11253        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11254        out: &mut TriggerProgramOutput<'_>,
11255        trigger_stack: &[String],
11256        config: &TriggerConfig,
11257        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
11258        depth: u32,
11259        selected: Option<&TriggerRowImage>,
11260        read_epoch: Epoch,
11261        control: Option<&crate::ExecutionControl>,
11262    ) -> Result<TriggerProgramOutcome> {
11263        let _ = depth;
11264        for (step_index, step) in steps.iter().enumerate() {
11265            commit_prepare_checkpoint(control, step_index)?;
11266            match step {
11267                TriggerStep::SetNew { cells } => {
11268                    if trigger.timing != TriggerTiming::Before {
11269                        return Err(MongrelError::InvalidArgument(
11270                            "SetNew trigger step is only valid in BEFORE triggers".into(),
11271                        ));
11272                    }
11273                    let put_idx = event.put_idx.ok_or_else(|| {
11274                        MongrelError::InvalidArgument(
11275                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
11276                        )
11277                    })?;
11278                    let staging = staging.as_deref_mut().ok_or_else(|| {
11279                        MongrelError::InvalidArgument(
11280                            "SetNew trigger step requires mutable trigger staging".into(),
11281                        )
11282                    })?;
11283                    let mut update_changed_columns = None;
11284                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
11285                        Some(crate::txn::Staged::Put(cells)) => cells,
11286                        Some(crate::txn::Staged::Update {
11287                            new_row,
11288                            changed_columns,
11289                            ..
11290                        }) => {
11291                            update_changed_columns = Some(changed_columns);
11292                            new_row
11293                        }
11294                        _ => {
11295                            return Err(MongrelError::InvalidArgument(
11296                                "SetNew trigger step target row is not mutable".into(),
11297                            ))
11298                        }
11299                    };
11300                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
11301                        row_cells.retain(|(id, _)| *id != column_id);
11302                        row_cells.push((column_id, value.clone()));
11303                        if let Some(changed_columns) = &mut update_changed_columns {
11304                            changed_columns.push(column_id);
11305                        }
11306                        if let Some(new) = &mut event.new {
11307                            new.columns.insert(column_id, value);
11308                        }
11309                    }
11310                    row_cells.sort_by_key(|(id, _)| *id);
11311                    if let Some(changed_columns) = update_changed_columns {
11312                        changed_columns.sort_unstable();
11313                        changed_columns.dedup();
11314                    }
11315                }
11316                TriggerStep::Insert { table, cells } => {
11317                    let cells = eval_trigger_cells(cells, event, selected)?;
11318                    if let Ok(table_id) = self.table_id(table) {
11319                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
11320                        out.added_stacks.push(trigger_stack.to_vec());
11321                    } else if self.external_table(table).is_some() {
11322                        out.added_external.push(ExternalTriggerWrite::Insert {
11323                            table: table.clone(),
11324                            cells,
11325                        });
11326                    } else {
11327                        return Err(MongrelError::NotFound(format!(
11328                            "trigger {:?} insert target {table:?} not found",
11329                            trigger.name
11330                        )));
11331                    }
11332                }
11333                TriggerStep::UpdateByPk { table, pk, cells } => {
11334                    let pk = eval_trigger_value(pk, event, selected)?;
11335                    let cells = eval_trigger_cells(cells, event, selected)?;
11336                    if self.external_table(table).is_some() {
11337                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
11338                            table: table.clone(),
11339                            pk,
11340                            cells,
11341                        });
11342                    } else {
11343                        let row_id = self
11344                            .table(table)?
11345                            .lock()
11346                            .lookup_pk(&pk.encode_key())
11347                            .ok_or_else(|| {
11348                                MongrelError::NotFound(format!(
11349                                    "trigger {:?} update target not found",
11350                                    trigger.name
11351                                ))
11352                            })?;
11353                        let handle = self.table(table)?;
11354                        let snapshot = self.snapshot_for_epoch(self.epoch.visible());
11355                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
11356                            MongrelError::NotFound(format!(
11357                                "trigger {:?} update target not visible",
11358                                trigger.name
11359                            ))
11360                        })?;
11361                        let mut changed_columns = cells
11362                            .iter()
11363                            .map(|(column_id, _)| *column_id)
11364                            .collect::<Vec<_>>();
11365                        changed_columns.sort_unstable();
11366                        changed_columns.dedup();
11367                        let mut merged = old.columns;
11368                        for (column_id, value) in cells {
11369                            merged.insert(column_id, value);
11370                        }
11371                        out.added.push((
11372                            self.table_id(table)?,
11373                            crate::txn::Staged::Update {
11374                                row_id,
11375                                new_row: merged.into_iter().collect(),
11376                                changed_columns,
11377                            },
11378                        ));
11379                        out.added_stacks.push(trigger_stack.to_vec());
11380                    }
11381                }
11382                TriggerStep::DeleteByPk { table, pk } => {
11383                    let pk = eval_trigger_value(pk, event, selected)?;
11384                    if self.external_table(table).is_some() {
11385                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
11386                            table: table.clone(),
11387                            pk,
11388                        });
11389                    } else {
11390                        let row_id = self
11391                            .table(table)?
11392                            .lock()
11393                            .lookup_pk(&pk.encode_key())
11394                            .ok_or_else(|| {
11395                                MongrelError::NotFound(format!(
11396                                    "trigger {:?} delete target not found",
11397                                    trigger.name
11398                                ))
11399                            })?;
11400                        out.added
11401                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
11402                        out.added_stacks.push(trigger_stack.to_vec());
11403                    }
11404                }
11405                TriggerStep::Select {
11406                    id,
11407                    table,
11408                    conditions,
11409                } => {
11410                    let schema = self.table(table)?.lock().schema().clone();
11411                    let snapshot = self.snapshot_for_epoch(read_epoch);
11412                    let handle = self.table(table)?;
11413                    let rows = match control {
11414                        Some(control) => {
11415                            handle.lock().visible_rows_controlled(snapshot, control)?
11416                        }
11417                        None => handle.lock().visible_rows(snapshot)?,
11418                    };
11419                    let mut matched = Vec::new();
11420                    for (row_index, row) in rows.into_iter().enumerate() {
11421                        commit_prepare_checkpoint(control, row_index)?;
11422                        let image = TriggerRowImage::from_row(row);
11423                        let passes = conditions
11424                            .iter()
11425                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11426                            .collect::<Result<Vec<_>>>()?
11427                            .into_iter()
11428                            .all(|b| b);
11429                        if passes {
11430                            matched.push(image);
11431                        }
11432                    }
11433                    if let Some(pk) = schema.primary_key() {
11434                        matched.sort_by(|a, b| {
11435                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
11436                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
11437                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
11438                        });
11439                    }
11440                    select_results.insert(id.clone(), matched);
11441                }
11442                TriggerStep::Foreach { id, steps } => {
11443                    let rows = select_results.get(id).ok_or_else(|| {
11444                        MongrelError::InvalidArgument(format!(
11445                            "trigger {:?} foreach references unknown select id {id:?}",
11446                            trigger.name
11447                        ))
11448                    })?;
11449                    if rows.len() > config.max_loop_iterations as usize {
11450                        return Err(MongrelError::InvalidArgument(format!(
11451                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
11452                            trigger.name, config.max_loop_iterations
11453                        )));
11454                    }
11455                    for (row_index, row) in rows.clone().into_iter().enumerate() {
11456                        commit_prepare_checkpoint(control, row_index)?;
11457                        let result = self.execute_trigger_steps(
11458                            trigger,
11459                            steps,
11460                            event,
11461                            staging.as_deref_mut(),
11462                            out,
11463                            trigger_stack,
11464                            config,
11465                            select_results,
11466                            depth + 1,
11467                            Some(&row),
11468                            read_epoch,
11469                            control,
11470                        )?;
11471                        if result == TriggerProgramOutcome::Ignore {
11472                            return Ok(TriggerProgramOutcome::Ignore);
11473                        }
11474                    }
11475                }
11476                TriggerStep::DeleteWhere { table, conditions } => {
11477                    let schema = self.table(table)?.lock().schema().clone();
11478                    let snapshot = self.snapshot_for_epoch(read_epoch);
11479                    let handle = self.table(table)?;
11480                    let rows = match control {
11481                        Some(control) => {
11482                            handle.lock().visible_rows_controlled(snapshot, control)?
11483                        }
11484                        None => handle.lock().visible_rows(snapshot)?,
11485                    };
11486                    let table_id = self.table_id(table)?;
11487                    let mut to_delete = Vec::new();
11488                    for (row_index, row) in rows.into_iter().enumerate() {
11489                        commit_prepare_checkpoint(control, row_index)?;
11490                        let image = TriggerRowImage::from_row(row.clone());
11491                        let passes = conditions
11492                            .iter()
11493                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11494                            .collect::<Result<Vec<_>>>()?
11495                            .into_iter()
11496                            .all(|b| b);
11497                        if passes {
11498                            to_delete.push((table_id, row.row_id));
11499                        }
11500                    }
11501                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
11502                        commit_prepare_checkpoint(control, row_index)?;
11503                        out.added
11504                            .push((table_id, crate::txn::Staged::Delete(row_id)));
11505                        out.added_stacks.push(trigger_stack.to_vec());
11506                    }
11507                }
11508                TriggerStep::UpdateWhere {
11509                    table,
11510                    conditions,
11511                    cells,
11512                } => {
11513                    let schema = self.table(table)?.lock().schema().clone();
11514                    let snapshot = self.snapshot_for_epoch(read_epoch);
11515                    let handle = self.table(table)?;
11516                    let rows = match control {
11517                        Some(control) => {
11518                            handle.lock().visible_rows_controlled(snapshot, control)?
11519                        }
11520                        None => handle.lock().visible_rows(snapshot)?,
11521                    };
11522                    let table_id = self.table_id(table)?;
11523                    let mut changed_columns =
11524                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
11525                    changed_columns.sort_unstable();
11526                    changed_columns.dedup();
11527                    let mut to_update = Vec::new();
11528                    for (row_index, row) in rows.into_iter().enumerate() {
11529                        commit_prepare_checkpoint(control, row_index)?;
11530                        let image = TriggerRowImage::from_row(row.clone());
11531                        let passes = conditions
11532                            .iter()
11533                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11534                            .collect::<Result<Vec<_>>>()?
11535                            .into_iter()
11536                            .all(|b| b);
11537                        if passes {
11538                            let new_cells = cells
11539                                .iter()
11540                                .map(|cell| {
11541                                    Ok((
11542                                        cell.column_id,
11543                                        eval_trigger_value(&cell.value, event, Some(&image))?,
11544                                    ))
11545                                })
11546                                .collect::<Result<Vec<_>>>()?;
11547                            let mut merged = row.columns.clone();
11548                            for (column_id, value) in new_cells {
11549                                merged.insert(column_id, value);
11550                            }
11551                            to_update.push((table_id, row.row_id, merged));
11552                        }
11553                    }
11554                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
11555                    {
11556                        commit_prepare_checkpoint(control, row_index)?;
11557                        out.added.push((
11558                            table_id,
11559                            crate::txn::Staged::Update {
11560                                row_id,
11561                                new_row: merged.into_iter().collect(),
11562                                changed_columns: changed_columns.clone(),
11563                            },
11564                        ));
11565                        out.added_stacks.push(trigger_stack.to_vec());
11566                    }
11567                }
11568                TriggerStep::Raise { action, message } => match action {
11569                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
11570                    TriggerRaiseAction::Abort
11571                    | TriggerRaiseAction::Fail
11572                    | TriggerRaiseAction::Rollback => {
11573                        let message = eval_trigger_value(message, event, selected)?;
11574                        return Err(MongrelError::TriggerValidation(format!(
11575                            "trigger {:?} raised: {}; trigger stack: {}",
11576                            trigger.name,
11577                            trigger_message(message),
11578                            Self::format_trigger_stack(trigger_stack)
11579                        )));
11580                    }
11581                },
11582            }
11583        }
11584        Ok(TriggerProgramOutcome::Continue)
11585    }
11586
11587    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
11588        let Some(stacks) = stacks else {
11589            return Vec::new();
11590        };
11591        let mut out = Vec::new();
11592        for idx in indices {
11593            let Some(stack) = stacks.get(*idx) else {
11594                continue;
11595            };
11596            for name in stack {
11597                if !out.iter().any(|existing| existing == name) {
11598                    out.push(name.clone());
11599                }
11600            }
11601        }
11602        out
11603    }
11604
11605    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
11606        let mut out = stack.to_vec();
11607        out.push(trigger_name.to_string());
11608        out
11609    }
11610
11611    fn format_trigger_stack(stack: &[String]) -> String {
11612        if stack.is_empty() {
11613            "<root>".into()
11614        } else {
11615            stack.join(" -> ")
11616        }
11617    }
11618
11619    /// Authoritatively validate every declared constraint on the staged write
11620    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
11621    /// SET NULL actions into explicit child ops. Called from
11622    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
11623    /// violation as an `Err`, aborting the commit atomically. This is the
11624    /// server-side authority point: concurrent remote writers that each pass
11625    /// their own client-side checks still cannot both commit a violating batch.
11626    ///
11627    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
11628    /// intra-transaction dedup; concurrent-txn races are additionally caught by
11629    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
11630    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
11631    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
11632    /// RESTRICT-only (cascade-truncate is unsupported).
11633    /// S1B-003: acquire Exclusive key claims for every primary key and declared
11634    /// UNIQUE key a transaction's staged puts/updates insert, in ascending key
11635    /// order (ordered acquisition cannot cycle on its own). A concurrent
11636    /// transaction claiming the same key blocks until this one ends, turning
11637    /// the optimistic write-write conflict into a serialization point. Claims
11638    /// release with the transaction's [`TxnLockGuard`].
11639    fn acquire_unique_key_claims(
11640        &self,
11641        txn_id: u64,
11642        staging: &[(u64, crate::txn::Staged)],
11643        control: Option<&crate::ExecutionControl>,
11644    ) -> Result<()> {
11645        let catalog = self.catalog.read();
11646        let has_uniques = staging.iter().any(|(table_id, staged)| {
11647            matches!(
11648                staged,
11649                crate::txn::Staged::Put(_) | crate::txn::Staged::Update { .. }
11650            ) && catalog.tables.iter().any(|entry| {
11651                entry.table_id == *table_id
11652                    && (entry.schema.primary_key().is_some()
11653                        || !entry.schema.constraints.uniques.is_empty())
11654            })
11655        });
11656        if !has_uniques {
11657            return Ok(());
11658        }
11659        let mut claims: Vec<(u64, Vec<u8>)> = Vec::new();
11660        for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11661            commit_prepare_checkpoint(control, staged_index)?;
11662            let cells = match staged {
11663                crate::txn::Staged::Put(cells) => cells,
11664                crate::txn::Staged::Update { new_row, .. } => new_row,
11665                _ => continue,
11666            };
11667            let Some(entry) = catalog
11668                .tables
11669                .iter()
11670                .find(|entry| entry.table_id == *table_id)
11671            else {
11672                continue;
11673            };
11674            for column in &entry.schema.columns {
11675                if !column
11676                    .flags
11677                    .contains(crate::schema::ColumnFlags::PRIMARY_KEY)
11678                {
11679                    continue;
11680                }
11681                if let Some((_, value)) = cells.iter().find(|(id, _)| *id == column.id) {
11682                    let mut key = b"pk:".to_vec();
11683                    key.extend_from_slice(&value.encode_key());
11684                    claims.push((*table_id, key));
11685                }
11686            }
11687            // Declared non-PK unique constraints claim their own namespace
11688            // (folding the constraint id into the key, per LockKey::Key's
11689            // multi-key-space rule). NULL components skip the constraint —
11690            // and the claim — per SQL semantics.
11691            let cells_map: HashMap<u16, Value> = cells.iter().cloned().collect();
11692            for uc in &entry.schema.constraints.uniques {
11693                if let Some(composite) =
11694                    crate::constraint::encode_composite_key(&uc.columns, &cells_map)
11695                {
11696                    let mut key = format!("uq{}:", uc.id).into_bytes();
11697                    key.extend_from_slice(&composite);
11698                    claims.push((*table_id, key));
11699                }
11700            }
11701        }
11702        claims.sort();
11703        claims.dedup();
11704        for (table_id, key) in claims {
11705            self.acquire_txn_lock(
11706                txn_id,
11707                crate::locks::LockKey::key(table_id, key),
11708                crate::locks::LockMode::Exclusive,
11709                control,
11710            )?;
11711        }
11712        Ok(())
11713    }
11714
11715    /// S1B-003: one FK parent-protection acquisition. `Err` propagates the
11716    /// deadlock/deadline/cancellation outcome; the test seam fires after each
11717    /// successful acquisition.
11718    fn acquire_fk_lock(
11719        &self,
11720        txn_id: u64,
11721        table_id: u64,
11722        key: &[u8],
11723        mode: crate::locks::LockMode,
11724        control: Option<&crate::ExecutionControl>,
11725    ) -> Result<()> {
11726        let mut namespaced = b"fk:".to_vec();
11727        namespaced.extend_from_slice(key);
11728        self.acquire_txn_lock(
11729            txn_id,
11730            crate::locks::LockKey::key(table_id, namespaced),
11731            mode,
11732            control,
11733        )?;
11734        // The hook is cloned out before firing: holding the slot's mutex while
11735        // a parked hook waits would block every other commit's hook call.
11736        let hook = self.fk_lock_hook.lock().clone();
11737        if let Some(hook) = hook {
11738            hook();
11739        }
11740        Ok(())
11741    }
11742
11743    fn validate_constraints(
11744        &self,
11745        txn_id: u64,
11746        staging: &mut Vec<(u64, crate::txn::Staged)>,
11747        read_epoch: Epoch,
11748        principal: Option<&crate::auth::Principal>,
11749        control: Option<&crate::ExecutionControl>,
11750    ) -> Result<()> {
11751        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
11752        use crate::memtable::Row;
11753        use crate::txn::Staged;
11754        use std::collections::HashSet;
11755
11756        commit_prepare_checkpoint(control, 0)?;
11757        // P0.5: constraint checks must use an HLC-pinned snapshot so parent /
11758        // unique rows stamped with commit_ts remain visible.
11759        let snapshot = self.snapshot_for_epoch(read_epoch);
11760        let cat = self.catalog.read().clone();
11761
11762        // Collect live (id, name, constraints-bearing?) for staged tables.
11763        let live: Vec<(u64, String, crate::schema::Schema)> = cat
11764            .tables
11765            .iter()
11766            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
11767            .map(|e| (e.table_id, e.name.clone(), e.schema.clone()))
11768            .collect();
11769
11770        // Fast path: bail if no live table declares any constraints at all.
11771        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
11772        if !any_constraints {
11773            self.materialize_generated_embeddings(staging, control)?;
11774            return Ok(());
11775        }
11776
11777        // Lazily-loaded visible rows per table, shared across checks.
11778        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
11779        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
11780            if let Some(r) = rows_cache.get(&table_id) {
11781                return Ok(r.clone());
11782            }
11783            let handle = self.table_by_id(table_id)?;
11784            let rows = match control {
11785                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
11786                None => handle.lock().visible_rows(snapshot)?,
11787            };
11788            rows_cache.insert(table_id, rows.clone());
11789            Ok(rows)
11790        };
11791
11792        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
11793        // carry an explicit old RowId + full new image. This makes action choice
11794        // reliable even when the referenced key itself changes; a delete+put
11795        // heuristic cannot distinguish that from unrelated operations.
11796        let mut processed_updates = HashSet::new();
11797        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
11798        let mut update_pass = 0_usize;
11799        loop {
11800            commit_prepare_checkpoint(control, update_pass)?;
11801            update_pass += 1;
11802            let updates: Vec<PendingUpdate> = staging
11803                .iter()
11804                .enumerate()
11805                .filter_map(|(index, (table_id, op))| match op {
11806                    Staged::Update {
11807                        row_id,
11808                        new_row: cells,
11809                        ..
11810                    } if !processed_updates.contains(&index) => {
11811                        Some((index, *table_id, *row_id, cells.clone()))
11812                    }
11813                    _ => None,
11814                })
11815                .collect();
11816            if updates.is_empty() {
11817                break;
11818            }
11819            let mut new_ops = Vec::new();
11820            for (update_index, (index, table_id, row_id, new_cells)) in
11821                updates.into_iter().enumerate()
11822            {
11823                commit_prepare_checkpoint(control, update_index)?;
11824                processed_updates.insert(index);
11825                let Some(tname) = live
11826                    .iter()
11827                    .find(|(id, _, _)| *id == table_id)
11828                    .map(|(_, name, _)| name.as_str())
11829                else {
11830                    continue;
11831                };
11832                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
11833                    continue;
11834                };
11835                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
11836                for (child_id, _child_name, child_schema) in &live {
11837                    for fk in &child_schema.constraints.foreign_keys {
11838                        if fk.ref_table != tname {
11839                            continue;
11840                        }
11841                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
11842                        else {
11843                            continue;
11844                        };
11845                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
11846                            == Some(old_key.as_slice())
11847                        {
11848                            continue;
11849                        }
11850                        if fk.on_update == FkAction::Restrict {
11851                            continue;
11852                        }
11853                        // S1B-003: the referenced key is being changed, so this
11854                        // update removes it for any action — hold an Exclusive
11855                        // parent-protection lock against concurrent child
11856                        // inserts referencing the old key.
11857                        self.acquire_fk_lock(
11858                            txn_id,
11859                            table_id,
11860                            &old_key,
11861                            crate::locks::LockMode::Exclusive,
11862                            control,
11863                        )?;
11864                        let child_rows = load_rows(*child_id)?;
11865                        for (child_index, child) in child_rows.into_iter().enumerate() {
11866                            commit_prepare_checkpoint(control, child_index)?;
11867                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
11868                                != Some(old_key.as_slice())
11869                            {
11870                                continue;
11871                            }
11872                            if staging.iter().any(|(id, op)| {
11873                                *id == *child_id
11874                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
11875                            }) {
11876                                continue;
11877                            }
11878                            let mut cells: Vec<(u16, Value)> = child
11879                                .columns
11880                                .iter()
11881                                .map(|(column_id, value)| (*column_id, value.clone()))
11882                                .collect();
11883                            for (child_column, parent_column) in
11884                                fk.columns.iter().zip(&fk.ref_columns)
11885                            {
11886                                cells.retain(|(column_id, _)| column_id != child_column);
11887                                let value = match fk.on_update {
11888                                    FkAction::Cascade => {
11889                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
11890                                    }
11891                                    FkAction::SetNull => Value::Null,
11892                                    FkAction::Restrict => {
11893                                        return Err(MongrelError::Other(
11894                                            "restricted foreign-key update reached cascade preparation"
11895                                                .into(),
11896                                        ));
11897                                    }
11898                                };
11899                                cells.push((*child_column, value));
11900                            }
11901                            cells.sort_by_key(|(column_id, _)| *column_id);
11902                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
11903                                *id == *child_id
11904                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
11905                            }) {
11906                                if let Staged::Update {
11907                                    new_row: existing,
11908                                    changed_columns,
11909                                    ..
11910                                } = &mut staging[existing_index].1 {
11911                                    changed_columns.extend(fk.columns.iter().copied());
11912                                    changed_columns.sort_unstable();
11913                                    changed_columns.dedup();
11914                                    if *existing != cells {
11915                                        *existing = cells;
11916                                        processed_updates.remove(&existing_index);
11917                                    }
11918                                }
11919                            } else {
11920                                new_ops.push((
11921                                    *child_id,
11922                                    Staged::Update {
11923                                        row_id: child.row_id,
11924                                        new_row: cells,
11925                                        changed_columns: fk.columns.clone(),
11926                                    },
11927                                ));
11928                            }
11929                        }
11930                    }
11931                }
11932            }
11933            staging.extend(new_ops);
11934        }
11935
11936        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
11937        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
11938        // enforced as a violation in Phase B. `cascaded` records every delete
11939        // we have already expanded so a self-referential CASCADE FK cannot loop.
11940        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
11941        let mut cascade_pass = 0_usize;
11942        loop {
11943            commit_prepare_checkpoint(control, cascade_pass)?;
11944            cascade_pass += 1;
11945            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
11946            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
11947                .iter()
11948                .filter_map(|(t, op)| match op {
11949                    Staged::Delete(rid) => Some((*t, *rid)),
11950                    _ => None,
11951                })
11952                .collect();
11953            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
11954                commit_prepare_checkpoint(control, delete_index)?;
11955                if !cascaded.insert((table_id, rid.0)) {
11956                    continue;
11957                }
11958                let Some(tname) = live
11959                    .iter()
11960                    .find(|(t, _, _)| *t == table_id)
11961                    .map(|(_, n, _)| n.as_str())
11962                else {
11963                    continue;
11964                };
11965                let parent_handle = self.table_by_id(table_id)?;
11966                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
11967                    continue;
11968                };
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 Some(parent_key) =
11975                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
11976                        else {
11977                            continue;
11978                        };
11979                        // Suppress ON DELETE cascade/set-null when this "delete"
11980                        // is actually half of an UPDATE encoded as Delete(old)+
11981                        // Put(new): if a staged Put in the SAME table still
11982                        // provides the referenced parent key, the parent still
11983                        // exists (its non-key columns changed) and the children
11984                        // must be left alone. A genuine delete, or an update
11985                        // that CHANGES the referenced key, has no preserving Put
11986                        // → cascade fires as before.
11987                        let key_preserved = staging.iter().any(|(t, op)| {
11988                            if *t != table_id {
11989                                return false;
11990                            }
11991                            let Staged::Put(cells) = op else {
11992                                return false;
11993                            };
11994                            let map: HashMap<u16, crate::memtable::Value> =
11995                                cells.iter().cloned().collect();
11996                            encode_composite_key(&fk.ref_columns, &map).as_deref()
11997                                == Some(parent_key.as_slice())
11998                        });
11999                        if key_preserved {
12000                            continue;
12001                        }
12002                        // S1B-003: the referenced parent key is genuinely being
12003                        // removed (delete, or the delete half of a key-changing
12004                        // update), for every FK action — hold an Exclusive
12005                        // parent-protection lock against concurrent child
12006                        // inserts referencing it. RESTRICT fks are enforced in
12007                        // Phase B; the claim covers them too.
12008                        self.acquire_fk_lock(
12009                            txn_id,
12010                            table_id,
12011                            &parent_key,
12012                            crate::locks::LockMode::Exclusive,
12013                            control,
12014                        )?;
12015                        match fk.on_delete {
12016                            FkAction::Restrict => continue,
12017                            FkAction::Cascade => {
12018                                let child_rows = load_rows(*child_id)?;
12019                                for (child_index, cr) in child_rows.iter().enumerate() {
12020                                    commit_prepare_checkpoint(control, child_index)?;
12021                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12022                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12023                                            == Some(parent_key.as_slice())
12024                                    {
12025                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
12026                                    }
12027                                }
12028                            }
12029                            FkAction::SetNull => {
12030                                let child_rows = load_rows(*child_id)?;
12031                                for (child_index, cr) in child_rows.iter().enumerate() {
12032                                    commit_prepare_checkpoint(control, child_index)?;
12033                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12034                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12035                                            == Some(parent_key.as_slice())
12036                                    {
12037                                        // Re-emit the child row with the FK
12038                                        // columns set to NULL (delete + put).
12039                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
12040                                            .columns
12041                                            .iter()
12042                                            .map(|(k, v)| (*k, v.clone()))
12043                                            .collect();
12044                                        for cid in &fk.columns {
12045                                            cells.retain(|(k, _)| k != cid);
12046                                            cells.push((*cid, crate::memtable::Value::Null));
12047                                        }
12048                                        new_ops.push((
12049                                            *child_id,
12050                                            Staged::Update {
12051                                                row_id: cr.row_id,
12052                                                new_row: cells,
12053                                                changed_columns: fk.columns.clone(),
12054                                            },
12055                                        ));
12056                                    }
12057                                }
12058                            }
12059                        }
12060                    }
12061                }
12062            }
12063            if new_ops.is_empty() {
12064                break;
12065            }
12066            staging.extend(new_ops);
12067        }
12068
12069        // Constraint actions can add writes. Re-authorize the final write set
12070        // before generated-value providers receive any row content, then
12071        // materialize vectors before Phase B validates the committed cells.
12072        self.validate_write_permissions(staging, principal, control)?;
12073        self.validate_security_writes(staging, read_epoch, principal, control)?;
12074        self.materialize_generated_embeddings(staging, control)?;
12075
12076        // Rows staged for deletion in THIS transaction (now including cascaded
12077        // deletes). Used to exclude the old version of an updated row from
12078        // unique-existence scans.
12079        let staged_deletes: HashSet<(u64, u64)> = staging
12080            .iter()
12081            .filter_map(|(t, op)| match op {
12082                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
12083                _ => None,
12084            })
12085            .collect();
12086
12087        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
12088        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
12089
12090        // ── Phase B: validate the fully-expanded staging set.
12091        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
12092            commit_prepare_checkpoint(control, operation_index)?;
12093            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id) else {
12094                continue;
12095            };
12096            let cells_map: HashMap<u16, crate::memtable::Value>;
12097            match op {
12098                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
12099                    cells_map = cells.iter().cloned().collect();
12100
12101                    // CHECK constraints.
12102                    if !schema.constraints.checks.is_empty() {
12103                        validate_checks(&schema.constraints.checks, &cells_map)?;
12104                    }
12105
12106                    // UNIQUE (non-PK) constraints.
12107                    for uc in &schema.constraints.uniques {
12108                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
12109                            continue; // NULL in a constrained column → skip (SQL).
12110                        };
12111                        let marker = (*table_id, uc.id, key.clone());
12112                        if !seen_unique.insert(marker) {
12113                            return Err(MongrelError::Conflict(format!(
12114                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
12115                                uc.name
12116                            )));
12117                        }
12118                        let rows = load_rows(*table_id)?;
12119                        for (row_index, r) in rows.iter().enumerate() {
12120                            commit_prepare_checkpoint(control, row_index)?;
12121                            // Skip rows this same transaction is deleting (the
12122                            // old version of an updated/cascade-deleted row).
12123                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
12124                                continue;
12125                            }
12126                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
12127                                if theirs == key {
12128                                    return Err(MongrelError::Conflict(format!(
12129                                        "UNIQUE constraint '{}' on table '{tname}' violated",
12130                                        uc.name
12131                                    )));
12132                                }
12133                            }
12134                        }
12135                    }
12136
12137                    // FK insert-side: parent must exist.
12138                    for fk in &schema.constraints.foreign_keys {
12139                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
12140                            continue; // NULL FK component → not checked (SQL).
12141                        };
12142                        let Some(parent_id) = cat
12143                            .tables
12144                            .iter()
12145                            .find(|t| t.name == fk.ref_table)
12146                            .map(|t| t.table_id)
12147                        else {
12148                            return Err(MongrelError::InvalidArgument(format!(
12149                                "FOREIGN KEY '{}' references unknown table '{}'",
12150                                fk.name, fk.ref_table
12151                            )));
12152                        };
12153                        // S1B-003: hold a Shared parent-protection lock on the
12154                        // referenced key while checking existence, so a
12155                        // concurrent parent delete or key-changing update
12156                        // serializes against this insert.
12157                        self.acquire_fk_lock(
12158                            txn_id,
12159                            parent_id,
12160                            &child_key,
12161                            crate::locks::LockMode::Shared,
12162                            control,
12163                        )?;
12164                        let parent_rows = load_rows(parent_id)?;
12165                        let mut found = false;
12166                        for (row_index, r) in parent_rows.iter().enumerate() {
12167                            commit_prepare_checkpoint(control, row_index)?;
12168                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
12169                                continue;
12170                            }
12171                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
12172                                if pkey == child_key {
12173                                    found = true;
12174                                    break;
12175                                }
12176                            }
12177                        }
12178                        // Final-write-set FK validation: a parent inserted in
12179                        // THIS transaction also satisfies the FK. This enables
12180                        // atomic parent+child batches and cyclical/mutual FK
12181                        // inserts within a single transaction — the child sees
12182                        // the staged parent put even though it is not committed
12183                        // yet.
12184                        if !found {
12185                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
12186                                commit_prepare_checkpoint(control, staged_index)?;
12187                                if *st_table != parent_id {
12188                                    continue;
12189                                }
12190                                if let Staged::Put(pcells)
12191                                | Staged::Update {
12192                                    new_row: pcells, ..
12193                                } = st_op
12194                                {
12195                                    let pmap: HashMap<u16, crate::memtable::Value> =
12196                                        pcells.iter().cloned().collect();
12197                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
12198                                    {
12199                                        if pkey == child_key {
12200                                            found = true;
12201                                            break;
12202                                        }
12203                                    }
12204                                }
12205                            }
12206                        }
12207                        if !found {
12208                            return Err(MongrelError::Conflict(format!(
12209                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
12210                                fk.name, fk.ref_table
12211                            )));
12212                        }
12213                    }
12214
12215                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
12216                    // expanded in Phase A; here the final child write set is
12217                    // known, so a child explicitly moved/deleted by this same
12218                    // transaction does not cause a false violation.
12219                    if let Staged::Update { row_id, .. } = op {
12220                        let parent_handle = self.table_by_id(*table_id)?;
12221                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
12222                            continue;
12223                        };
12224                        for (child_id, child_name, child_schema) in &live {
12225                            for fk in &child_schema.constraints.foreign_keys {
12226                                if fk.ref_table != *tname || fk.on_update != FkAction::Restrict {
12227                                    continue;
12228                                }
12229                                let Some(old_key) =
12230                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
12231                                else {
12232                                    continue;
12233                                };
12234                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
12235                                    == Some(old_key.as_slice())
12236                                {
12237                                    continue;
12238                                }
12239                                // S1B-003: the referenced key is being changed —
12240                                // hold an Exclusive parent-protection lock
12241                                // against concurrent child inserts referencing
12242                                // the old key.
12243                                self.acquire_fk_lock(
12244                                    txn_id,
12245                                    *table_id,
12246                                    &old_key,
12247                                    crate::locks::LockMode::Exclusive,
12248                                    control,
12249                                )?;
12250                                for (child_index, child) in
12251                                    load_rows(*child_id)?.into_iter().enumerate()
12252                                {
12253                                    commit_prepare_checkpoint(control, child_index)?;
12254                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
12255                                        != Some(old_key.as_slice())
12256                                    {
12257                                        continue;
12258                                    }
12259                                    let replacement = staging.iter().find_map(|(id, op)| {
12260                                        if *id != *child_id {
12261                                            return None;
12262                                        }
12263                                        match op {
12264                                            Staged::Delete(id) if *id == child.row_id => Some(None),
12265                                            Staged::Update {
12266                                                row_id,
12267                                                new_row: cells,
12268                                                ..
12269                                            } if *row_id == child.row_id => {
12270                                                let map: HashMap<u16, Value> =
12271                                                    cells.iter().cloned().collect();
12272                                                Some(encode_composite_key(&fk.columns, &map))
12273                                            }
12274                                            _ => None,
12275                                        }
12276                                    });
12277                                    if replacement.is_some_and(|key| {
12278                                        key.as_deref() != Some(old_key.as_slice())
12279                                    }) {
12280                                        continue;
12281                                    }
12282                                    return Err(MongrelError::Conflict(format!(
12283                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
12284                                        fk.name
12285                                    )));
12286                                }
12287                            }
12288                        }
12289                    }
12290                }
12291                Staged::Delete(rid) => {
12292                    // FK ON DELETE RESTRICT: a child row (whose FK action is
12293                    // RESTRICT) referencing this parent blocks the delete.
12294                    // CASCADE/SET NULL children were expanded in Phase A.
12295                    let parent_handle = self.table_by_id(*table_id)?;
12296                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
12297                        continue;
12298                    };
12299                    for (child_id, child_name, child_schema) in &live {
12300                        for fk in &child_schema.constraints.foreign_keys {
12301                            if fk.ref_table != *tname || fk.on_delete != FkAction::Restrict {
12302                                continue;
12303                            }
12304                            let Some(parent_key) =
12305                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
12306                            else {
12307                                continue;
12308                            };
12309                            let child_rows = load_rows(*child_id)?;
12310                            for (row_index, r) in child_rows.iter().enumerate() {
12311                                commit_prepare_checkpoint(control, row_index)?;
12312                                // A child already being deleted by this txn
12313                                // (cascade/inline) is not a restrict violation.
12314                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
12315                                    continue;
12316                                }
12317                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
12318                                    if ck == parent_key {
12319                                        return Err(MongrelError::Conflict(format!(
12320                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
12321                                            fk.name
12322                                        )));
12323                                    }
12324                                }
12325                            }
12326                        }
12327                    }
12328                }
12329                Staged::Truncate => {
12330                    // Truncate is RESTRICT-only: reject if any child references
12331                    // this table (any FK action), since cascade-truncate is
12332                    // unsupported.
12333                    for (child_id, child_name, child_schema) in &live {
12334                        for fk in &child_schema.constraints.foreign_keys {
12335                            if fk.ref_table != *tname {
12336                                continue;
12337                            }
12338                            let child_rows = load_rows(*child_id)?;
12339                            if child_rows
12340                                .iter()
12341                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
12342                            {
12343                                return Err(MongrelError::Conflict(format!(
12344                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
12345                                    fk.name
12346                                )));
12347                            }
12348                        }
12349                    }
12350                }
12351            }
12352        }
12353        Ok(())
12354    }
12355
12356    fn validate_write_permissions(
12357        &self,
12358        staging: &[(u64, crate::txn::Staged)],
12359        principal: Option<&crate::auth::Principal>,
12360        control: Option<&crate::ExecutionControl>,
12361    ) -> Result<()> {
12362        commit_prepare_checkpoint(control, 0)?;
12363        if principal.is_none() && !self.auth_state.require_auth() {
12364            return Ok(());
12365        }
12366        let principal = principal.ok_or(MongrelError::AuthRequired)?;
12367        let needs = summarize_write_permissions(staging);
12368        let catalog = self.catalog.read();
12369
12370        if needs.values().any(|need| need.truncate) {
12371            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
12372        }
12373        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
12374            commit_prepare_checkpoint(control, need_index)?;
12375            let entry = catalog
12376                .tables
12377                .iter()
12378                .find(|entry| {
12379                    entry.table_id == table_id
12380                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
12381                })
12382                .ok_or_else(|| {
12383                    MongrelError::NotFound(format!(
12384                        "live table {table_id} not found during write validation"
12385                    ))
12386                })?;
12387            if matches!(entry.state, TableState::Building { .. }) {
12388                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
12389                continue;
12390            }
12391            if need.insert {
12392                Self::require_columns_for_principal(
12393                    &entry.name,
12394                    &entry.schema,
12395                    crate::auth::ColumnOperation::Insert,
12396                    &need.insert_columns,
12397                    principal,
12398                )?;
12399            }
12400            if need.update {
12401                Self::require_columns_for_principal(
12402                    &entry.name,
12403                    &entry.schema,
12404                    crate::auth::ColumnOperation::Update,
12405                    &need.update_columns,
12406                    principal,
12407                )?;
12408            }
12409            if need.delete {
12410                self.require_for(
12411                    Some(principal),
12412                    &crate::auth::Permission::Delete {
12413                        table: entry.name.clone(),
12414                    },
12415                )?;
12416            }
12417        }
12418        Ok(())
12419    }
12420
12421    fn validate_security_writes(
12422        &self,
12423        staging: &[(u64, crate::txn::Staged)],
12424        read_epoch: Epoch,
12425        explicit_principal: Option<&crate::auth::Principal>,
12426        control: Option<&crate::ExecutionControl>,
12427    ) -> Result<()> {
12428        commit_prepare_checkpoint(control, 0)?;
12429        use crate::security::PolicyCommand;
12430        use crate::txn::Staged;
12431
12432        let catalog = self.catalog.read();
12433        if catalog.security.rls_tables.is_empty() {
12434            return Ok(());
12435        }
12436        let security = catalog.security.clone();
12437        let table_names = catalog
12438            .tables
12439            .iter()
12440            .filter(|entry| matches!(entry.state, TableState::Live))
12441            .map(|entry| (entry.table_id, entry.name.clone()))
12442            .collect::<HashMap<_, _>>();
12443        drop(catalog);
12444        if !staging.iter().any(|(table_id, _)| {
12445            table_names
12446                .get(table_id)
12447                .is_some_and(|table| security.rls_enabled(table))
12448        }) {
12449            return Ok(());
12450        }
12451        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
12452
12453        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
12454            commit_prepare_checkpoint(control, operation_index)?;
12455            let Some(table) = table_names.get(table_id) else {
12456                continue;
12457            };
12458            if !security.rls_enabled(table) || principal.is_admin {
12459                continue;
12460            }
12461            let denied = |command| MongrelError::PermissionDenied {
12462                required: match command {
12463                    PolicyCommand::Insert => crate::auth::Permission::Insert {
12464                        table: table.clone(),
12465                    },
12466                    PolicyCommand::Update => crate::auth::Permission::Update {
12467                        table: table.clone(),
12468                    },
12469                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
12470                        crate::auth::Permission::Delete {
12471                            table: table.clone(),
12472                        }
12473                    }
12474                },
12475                principal: principal.username.clone(),
12476            };
12477            match operation {
12478                Staged::Put(cells) => {
12479                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
12480                    row.columns.extend(cells.iter().cloned());
12481                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
12482                        return Err(denied(PolicyCommand::Insert));
12483                    }
12484                }
12485                Staged::Update {
12486                    row_id,
12487                    new_row: cells,
12488                    ..
12489                } => {
12490                    let old = self
12491                        .table_by_id(*table_id)?
12492                        .lock()
12493                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12494                        .ok_or_else(|| {
12495                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12496                        })?;
12497                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
12498                        return Err(denied(PolicyCommand::Update));
12499                    }
12500                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
12501                    new.columns.extend(cells.iter().cloned());
12502                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
12503                        return Err(denied(PolicyCommand::Update));
12504                    }
12505                }
12506                Staged::Delete(row_id) => {
12507                    let old = self
12508                        .table_by_id(*table_id)?
12509                        .lock()
12510                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12511                        .ok_or_else(|| {
12512                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12513                        })?;
12514                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
12515                        return Err(denied(PolicyCommand::Delete));
12516                    }
12517                }
12518                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
12519            }
12520        }
12521        Ok(())
12522    }
12523
12524    /// Seal a transaction (spec §9.3):
12525    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
12526    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
12527    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
12528    ///    group-sync, record conflict keys.
12529    /// 3. Publish — apply to tables, advance visible in-order.
12530    #[allow(clippy::too_many_arguments)]
12531    pub(crate) fn commit_transaction_with_external_states(
12532        &self,
12533        txn_id: u64,
12534        read_epoch: Epoch,
12535        staging: Vec<(u64, crate::txn::Staged)>,
12536        external_states: Vec<(String, Vec<u8>)>,
12537        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12538        security_principal: Option<crate::auth::Principal>,
12539        principal_catalog_bound: bool,
12540        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12541        context: crate::txn::TxnCommitContext,
12542    ) -> Result<(Epoch, Vec<RowId>)> {
12543        self.commit_transaction_with_external_states_inner(
12544            txn_id,
12545            read_epoch,
12546            staging,
12547            external_states,
12548            materialized_view_updates,
12549            security_principal,
12550            principal_catalog_bound,
12551            external_trigger_bridge,
12552            context,
12553            None,
12554            None,
12555        )
12556    }
12557
12558    #[allow(clippy::too_many_arguments)]
12559    pub(crate) fn commit_transaction_with_external_states_controlled(
12560        &self,
12561        txn_id: u64,
12562        read_epoch: Epoch,
12563        staging: Vec<(u64, crate::txn::Staged)>,
12564        external_states: Vec<(String, Vec<u8>)>,
12565        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12566        security_principal: Option<crate::auth::Principal>,
12567        principal_catalog_bound: bool,
12568        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12569        context: crate::txn::TxnCommitContext,
12570        control: &crate::ExecutionControl,
12571        before_commit: &mut dyn FnMut() -> Result<()>,
12572    ) -> Result<(Epoch, Vec<RowId>)> {
12573        self.commit_transaction_with_external_states_inner(
12574            txn_id,
12575            read_epoch,
12576            staging,
12577            external_states,
12578            materialized_view_updates,
12579            security_principal,
12580            principal_catalog_bound,
12581            external_trigger_bridge,
12582            context,
12583            Some(control),
12584            Some(before_commit),
12585        )
12586    }
12587
12588    #[allow(clippy::too_many_arguments)]
12589    fn commit_transaction_with_external_states_inner(
12590        &self,
12591        txn_id: u64,
12592        read_epoch: Epoch,
12593        mut staging: Vec<(u64, crate::txn::Staged)>,
12594        external_states: Vec<(String, Vec<u8>)>,
12595        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12596        mut security_principal: Option<crate::auth::Principal>,
12597        principal_catalog_bound: bool,
12598        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12599        context: crate::txn::TxnCommitContext,
12600        control: Option<&crate::ExecutionControl>,
12601        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
12602    ) -> Result<(Epoch, Vec<RowId>)> {
12603        use crate::memtable::Row;
12604        use crate::txn::{Staged, StagedOp, WriteKey};
12605        use crate::wal::Op;
12606        use std::collections::hash_map::DefaultHasher;
12607        use std::hash::{Hash, Hasher};
12608        use std::sync::atomic::Ordering;
12609
12610        if txn_id == crate::wal::SYSTEM_TXN_ID {
12611            return Err(MongrelError::Full(
12612                "per-open transaction id namespace exhausted; reopen the database".into(),
12613            ));
12614        }
12615        if self.read_only {
12616            return Err(MongrelError::ReadOnlyReplica);
12617        }
12618        commit_prepare_checkpoint(control, 0)?;
12619        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
12620        self.refresh_security_catalog_if_stale(observed_security_version)?;
12621        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
12622        if self.auth_state.require_auth() && security_principal.is_none() {
12623            return Err(MongrelError::AuthRequired);
12624        }
12625        {
12626            let catalog = self.catalog.read();
12627            if principal_catalog_bound
12628                || security_principal
12629                    .as_ref()
12630                    .is_some_and(|principal| principal.user_id != 0)
12631            {
12632                let principal = security_principal
12633                    .as_ref()
12634                    .ok_or(MongrelError::AuthRequired)?;
12635                security_principal =
12636                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
12637                if security_principal.is_none() {
12638                    return Err(MongrelError::AuthRequired);
12639                }
12640            }
12641        }
12642        let _replication_guard = self.replication_barrier.read();
12643        if self.poisoned.load(Ordering::Relaxed) {
12644            return Err(MongrelError::Other(
12645                "database poisoned by fsync error".into(),
12646            ));
12647        }
12648        // S1A-004: admit the commit as one core operation (rejects once the
12649        // core is draining, closing, closed, or lifecycle-poisoned; the legacy
12650        // fsync-poison error above still wins for a WAL-poisoned core).
12651        let _operation = self.admit_operation()?;
12652
12653        // ── S1B-003: user transactions hold the schema barrier Shared for the
12654        // whole commit so DDL (Exclusive) excludes concurrent DML. Internal
12655        // commits (catalog backfills, external-table state — `context.state`
12656        // is `None`) skip it: they run under their DDL operation's own
12657        // Exclusive hold, and acquiring a second, differently-keyed hold here
12658        // would self-deadlock. The guard releases every lock this commit
12659        // acquired — schema barrier, unique claims, sequence barriers, FK
12660        // holds — on every exit path (S1B-004 step 12).
12661        if context.state.is_some() {
12662            self.acquire_txn_lock(
12663                txn_id,
12664                crate::locks::LockKey::schema_barrier(),
12665                crate::locks::LockMode::Shared,
12666                control,
12667            )?;
12668        }
12669        let _txn_lock_guard = TxnLockGuard {
12670            locks: &self.lock_manager,
12671            txn_id,
12672        };
12673
12674        // ── S1B-005: idempotency check BEFORE the proposal. A repeated key
12675        // with an identical request replays the original receipt without
12676        // re-executing; a different request under the same key conflicts; a
12677        // new key is reserved durably before any WAL record can become
12678        // durable, so a crash mid-commit fails closed on retry.
12679        let idempotency_request = match &context.idempotency {
12680            Some(request) => match self.idempotency.check_and_reserve(request)? {
12681                crate::txn::IdempotencyCheck::Replay(receipt) => {
12682                    let epoch = Epoch(receipt.log_position.index);
12683                    if let Some(state) = &context.state {
12684                        state.committed(receipt);
12685                    }
12686                    return Ok((epoch, Vec::new()));
12687                }
12688                crate::txn::IdempotencyCheck::Reserved => Some(request.clone()),
12689            },
12690            None => None,
12691        };
12692        // Release the reservation on every pre-receipt failure path.
12693        let mut idempotency_guard = idempotency_request.as_ref().map(|request| {
12694            crate::txn::IdempotencyReservationGuard::new(&self.idempotency, request.clone())
12695        });
12696        let mut external_states = dedup_external_states(external_states);
12697        if !external_states.is_empty() {
12698            let cat = self.catalog.read();
12699            for (name, _) in &external_states {
12700                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
12701                    return Err(MongrelError::NotFound(format!(
12702                        "external table {name:?} not found"
12703                    )));
12704                }
12705            }
12706        }
12707        let prepared_materialized_views = {
12708            let mut deduplicated = HashMap::new();
12709            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
12710            {
12711                commit_prepare_checkpoint(control, definition_index)?;
12712                if definition.name.is_empty() || definition.query.trim().is_empty() {
12713                    return Err(MongrelError::InvalidArgument(
12714                        "materialized view name and query must not be empty".into(),
12715                    ));
12716                }
12717                deduplicated.insert(definition.name.clone(), definition);
12718            }
12719            let catalog = self.catalog.read();
12720            let mut prepared = Vec::with_capacity(deduplicated.len());
12721            for (definition_index, definition) in deduplicated.into_values().enumerate() {
12722                commit_prepare_checkpoint(control, definition_index)?;
12723                let table_id = catalog
12724                    .live(&definition.name)
12725                    .ok_or_else(|| {
12726                        MongrelError::NotFound(format!(
12727                            "materialized view table {:?} not found",
12728                            definition.name
12729                        ))
12730                    })?
12731                    .table_id;
12732                prepared.push((table_id, definition));
12733            }
12734            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
12735            prepared
12736        };
12737
12738        // ── 1. Prepare: fill generated values, expand triggers, validate, then
12739        // derive write keys from the final atomic write set.
12740        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12741        self.expand_table_triggers(
12742            txn_id,
12743            &mut staging,
12744            read_epoch,
12745            external_trigger_bridge,
12746            &mut external_states,
12747            control,
12748        )?;
12749        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12750        external_states = dedup_external_states(external_states);
12751        let expected_external_generations = {
12752            let catalog = self.catalog.read();
12753            let mut generations = HashMap::with_capacity(external_states.len());
12754            for (name, _) in &external_states {
12755                let entry = catalog
12756                    .external_tables
12757                    .iter()
12758                    .find(|entry| entry.name == *name)
12759                    .ok_or_else(|| {
12760                        MongrelError::NotFound(format!("external table {name:?} not found"))
12761                    })?;
12762                generations.insert(name.clone(), entry.created_epoch);
12763            }
12764            generations
12765        };
12766
12767        // S1B-003: claim every unique key this transaction inserts (primary
12768        // keys and declared UNIQUE constraints) in Exclusive mode BEFORE
12769        // validation reads. Concurrent inserts of the same key serialize on
12770        // the claim instead of racing to a write-write conflict; the
12771        // first-committer-wins index below remains the safety net.
12772        self.acquire_unique_key_claims(txn_id, &staging, control)?;
12773        // Fail unauthorized source writes before constraint expansion reads
12774        // existing rows. Constraint-generated writes are checked again inside
12775        // `validate_constraints` before any embedding provider is invoked.
12776        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
12777        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
12778        // Validate declarative constraints (unique / FK / check) under the read
12779        // snapshot, outside the WAL mutex. Trigger-produced writes are included
12780        // here, so the batch either satisfies every declared constraint or is
12781        // rejected atomically. This also materializes generated vectors after
12782        // all trigger and constraint-action writes exist, but before WAL append.
12783        self.validate_constraints(
12784            txn_id,
12785            &mut staging,
12786            read_epoch,
12787            security_principal.as_ref(),
12788            control,
12789        )?;
12790        let mut normalized = Vec::with_capacity(staging.len() * 2);
12791        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
12792            commit_prepare_checkpoint(control, staged_index)?;
12793            match op {
12794                crate::txn::Staged::Update {
12795                    row_id,
12796                    new_row: cells,
12797                    ..
12798                } => {
12799                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
12800                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
12801                }
12802                op => normalized.push((table_id, op)),
12803            }
12804        }
12805        staging = normalized;
12806        let has_changes = !staging.is_empty()
12807            || !external_states.is_empty()
12808            || !prepared_materialized_views.is_empty();
12809        let truncated_tables: HashSet<u64> = staging
12810            .iter()
12811            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
12812            .collect();
12813
12814        let write_keys = {
12815            let cat = self.catalog.read();
12816            let mut keys: Vec<WriteKey> = Vec::new();
12817            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12818                commit_prepare_checkpoint(control, staged_index)?;
12819                match staged {
12820                    Staged::Put(cells) => {
12821                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
12822                            for col in &entry.schema.columns {
12823                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
12824                                    if let Some((_, val)) =
12825                                        cells.iter().find(|(id, _)| *id == col.id)
12826                                    {
12827                                        let mut h = DefaultHasher::new();
12828                                        val.encode_key().hash(&mut h);
12829                                        keys.push(WriteKey::Unique {
12830                                            table_id: *table_id,
12831                                            index_id: 0,
12832                                            key_hash: h.finish(),
12833                                        });
12834                                    }
12835                                }
12836                            }
12837                            // Declared non-PK unique constraints register a
12838                            // `WriteKey::Unique` (namespace-separated from the
12839                            // PK's index_id==0 by setting the high bit) so two
12840                            // concurrent transactions inserting the same key
12841                            // cannot both commit. Rows with any NULL constrained
12842                            // column are skipped (SQL semantics).
12843                            for uc in &entry.schema.constraints.uniques {
12844                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
12845                                    &uc.columns,
12846                                    &cells.iter().cloned().collect(),
12847                                ) {
12848                                    let mut h = DefaultHasher::new();
12849                                    key_bytes.hash(&mut h);
12850                                    keys.push(WriteKey::Unique {
12851                                        table_id: *table_id,
12852                                        index_id: uc.id | 0x8000,
12853                                        key_hash: h.finish(),
12854                                    });
12855                                }
12856                            }
12857                        }
12858                    }
12859                    Staged::Delete(rid) => keys.push(WriteKey::Row {
12860                        table_id: *table_id,
12861                        row_id: rid.0,
12862                    }),
12863                    Staged::Truncate => keys.push(WriteKey::Table {
12864                        table_id: *table_id,
12865                    }),
12866                    Staged::Update { .. } => {
12867                        return Err(MongrelError::Other(
12868                            "transaction contains an unnormalized update during preparation".into(),
12869                        ));
12870                    }
12871                }
12872            }
12873            for (external_index, (name, _)) in external_states.iter().enumerate() {
12874                commit_prepare_checkpoint(control, external_index)?;
12875                let mut h = DefaultHasher::new();
12876                name.hash(&mut h);
12877                keys.push(WriteKey::Unique {
12878                    table_id: EXTERNAL_TABLE_ID,
12879                    index_id: 0,
12880                    key_hash: h.finish(),
12881                });
12882            }
12883            keys
12884        };
12885
12886        // Opportunistic pruning.
12887        let min_active = self.active_txns.min_read_epoch();
12888        if min_active < u64::MAX {
12889            self.conflicts.prune_below(Epoch(min_active));
12890        }
12891
12892        // S1B-002 (Serializable): SSI certification keys — every tracked point
12893        // read as a row key, every tracked predicate/range read as a table
12894        // key. A concurrent commit covering any of them after this
12895        // transaction's read epoch is an rw-antidependency dangerous
12896        // structure; certification aborts rather than allowing a
12897        // non-serializable interleaving.
12898        let ssi_keys = match context.isolation.canonical() {
12899            crate::txn::IsolationLevel::Serializable => {
12900                crate::txn::ssi_validation_keys(&context.read_set, &context.predicate_set)
12901            }
12902            _ => Vec::new(),
12903        };
12904
12905        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
12906        // §8.5, review fix #17). Snapshot the conflict-index version so the
12907        // sequencer only re-checks if new commits arrived in the interim.
12908        if self.conflicts.conflicts(&write_keys, read_epoch) {
12909            return Err(MongrelError::Conflict(
12910                "write-write conflict (pre-validate, first-committer-wins)".into(),
12911            ));
12912        }
12913        if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
12914            return Err(MongrelError::SerializationFailure {
12915                message: "a concurrent commit invalidated this transaction's reads (pre-validate)"
12916                    .into(),
12917            });
12918        }
12919        let pre_validate_version = self.conflicts.version();
12920
12921        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
12922        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
12923        // streamed as Put records; they are linked at publish time.
12924        let mut spilled: Vec<SpilledRun> = Vec::new();
12925        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
12926        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
12927        // as the spill runs are live (registered on first spill, dropped at the
12928        // end of this function on commit/abort/error).
12929        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
12930        {
12931            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
12932            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
12933            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12934                commit_prepare_checkpoint(control, staged_index)?;
12935                if let Staged::Put(cells) = staged {
12936                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
12937                        bytes.saturating_add(value.estimated_bytes())
12938                    });
12939                    let table_bytes = table_bytes.entry(*table_id).or_default();
12940                    *table_bytes = table_bytes.saturating_add(bytes);
12941                    put_indexes.entry(*table_id).or_default().push(staged_index);
12942                }
12943            }
12944            let tables = self.tables.read();
12945            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
12946                commit_prepare_checkpoint(control, table_index)?;
12947                if bytes
12948                    <= self
12949                        .spill_threshold
12950                        .load(std::sync::atomic::Ordering::Relaxed)
12951                {
12952                    continue;
12953                }
12954                let Some(handle) = tables.get(&table_id) else {
12955                    continue;
12956                };
12957                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
12958                let mut t = handle.lock();
12959                let tdir = t.table_dir().to_path_buf();
12960                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
12961                std::fs::create_dir_all(&txn_dir)?;
12962                let run_id = t.alloc_run_id()? as u128;
12963                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
12964                let final_path = t.run_path(run_id as u64);
12965
12966                let mut rows: Vec<Row> = Vec::new();
12967                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
12968                    commit_prepare_checkpoint(control, put_index)?;
12969                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
12970                        return Err(MongrelError::Other(
12971                            "transaction put index no longer references a put".into(),
12972                        ));
12973                    };
12974                    t.validate_cells_not_null(cells)?;
12975                    let row_id = t.alloc_row_id()?;
12976                    let mut row = Row::new(row_id, Epoch(0));
12977                    row.columns.extend(std::mem::take(cells));
12978                    rows.push(row);
12979                }
12980                let schema = t.schema_ref().clone();
12981                let kek = t.kek_ref().cloned();
12982                let specs = t.indexable_column_specs();
12983                drop(t);
12984
12985                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
12986                    .uniform_epoch(true);
12987                if let Some(ref kek) = kek {
12988                    writer = writer.with_encryption(kek.as_ref(), specs);
12989                }
12990                commit_prepare_checkpoint(control, 0)?;
12991                let header = writer.write(&pending_path, &rows)?;
12992                commit_prepare_checkpoint(control, 0)?;
12993                let row_count = header.row_count;
12994                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
12995                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
12996
12997                spilled.push(SpilledRun {
12998                    table_id,
12999                    run_id,
13000                    pending_path,
13001                    final_path,
13002                    rows,
13003                    row_count,
13004                    min_rid,
13005                    max_rid,
13006                    content_hash: header.content_hash,
13007                });
13008                spilled_tables.insert(table_id);
13009            }
13010        }
13011
13012        // Test seam: let a test race `gc()` against this in-flight spill.
13013        if spill_guard.is_some() {
13014            if let Some(hook) = self.spill_hook.lock().as_ref() {
13015                hook();
13016            }
13017        }
13018
13019        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
13020        // Allocating row ids + building the rows here (lock order: table handle →
13021        // nothing) means the sequencer never locks a table handle while holding
13022        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
13023        // the table handle THEN the shared WAL; if the sequencer did the reverse
13024        // (WAL then handle) the two paths would deadlock (review fix: B1).
13025        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
13026        // Row ids are allocated here, before the sequencer's delta conflict
13027        // re-check, so a losing txn leaks the ids it reserved — harmless, the
13028        // u64 row-id space is monotonic and gaps are expected (spills do the same).
13029        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13030            .take(staging.len())
13031            .collect();
13032        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13033            .take(staging.len())
13034            .collect();
13035        {
13036            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
13037            for (index, (table_id, staged)) in staging.iter().enumerate() {
13038                commit_prepare_checkpoint(control, index)?;
13039                if matches!(staged, Staged::Delete(_))
13040                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
13041                {
13042                    indexes_by_table.entry(*table_id).or_default().push(index);
13043                }
13044            }
13045            let tables = self.tables.read();
13046            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
13047                commit_prepare_checkpoint(control, table_index)?;
13048                let handle = tables.get(&table_id).ok_or_else(|| {
13049                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13050                })?;
13051                #[cfg(test)]
13052                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13053                let mut t = handle.lock();
13054                for (prepare_index, index) in indexes.into_iter().enumerate() {
13055                    commit_prepare_checkpoint(control, prepare_index)?;
13056                    match &staging[index].1 {
13057                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
13058                            t.validate_cells_not_null(cells)?;
13059                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
13060                            for (column, value) in cells {
13061                                row.columns.insert(*column, value.clone());
13062                            }
13063                            prebuilt[index] = Some(row);
13064                        }
13065                        Staged::Delete(row_id) => {
13066                            delete_images[index] =
13067                                t.get(*row_id, self.snapshot_for_epoch(read_epoch));
13068                        }
13069                        Staged::Put(_) | Staged::Truncate => {}
13070                        Staged::Update { .. } => {
13071                            return Err(MongrelError::Other(
13072                                "transaction contains an unnormalized update during row preparation"
13073                                    .into(),
13074                            ));
13075                        }
13076                    }
13077                }
13078            }
13079        }
13080
13081        // Finish every fallible index read before the commit marker can become
13082        // durable. Post-durable row/run metadata application is then entirely
13083        // in-memory and cannot stop halfway through a multi-table publish.
13084        let prepared_table_handles = {
13085            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
13086            let put_table_ids: HashSet<u64> = staging
13087                .iter()
13088                .filter_map(|(table_id, staged)| {
13089                    matches!(staged, Staged::Put(_)).then_some(*table_id)
13090                })
13091                .collect();
13092            let tables = self.tables.read();
13093            let mut handles = HashMap::with_capacity(table_ids.len());
13094            for (table_index, table_id) in table_ids.into_iter().enumerate() {
13095                commit_prepare_checkpoint(control, table_index)?;
13096                let handle = tables.get(&table_id).ok_or_else(|| {
13097                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13098                })?;
13099                if put_table_ids.contains(&table_id) {
13100                    match control {
13101                        Some(control) => {
13102                            handle.lock().prepare_durable_publish_controlled(control)?
13103                        }
13104                        None => handle.lock().prepare_durable_publish()?,
13105                    }
13106                }
13107                handles.insert(table_id, handle.clone());
13108            }
13109            handles
13110        };
13111
13112        // Link large-transaction spill files before WAL durability. The guard
13113        // restores their pending names on every error before WAL append begins;
13114        // publication only attaches already-present files in memory.
13115        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
13116
13117        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
13118            .iter()
13119            .map(|run| {
13120                (
13121                    run.table_id,
13122                    run.rows.iter().map(|row| row.row_id).collect(),
13123                )
13124            })
13125            .collect();
13126        let committed_row_ids = staging
13127            .iter()
13128            .enumerate()
13129            .filter_map(|(index, (table_id, staged))| {
13130                if !matches!(staged, Staged::Put(_)) {
13131                    return None;
13132                }
13133                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
13134                    spilled_row_ids
13135                        .get_mut(table_id)
13136                        .and_then(VecDeque::pop_front)
13137                })
13138            })
13139            .collect();
13140
13141        let mut prepared_external = Vec::with_capacity(external_states.len());
13142        for (external_index, (name, state)) in external_states.iter().enumerate() {
13143            commit_prepare_checkpoint(control, external_index)?;
13144            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
13145            prepared_external.push((name.clone(), state.clone(), pending));
13146        }
13147
13148        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
13149        //
13150        // The schema barrier's Shared hold covered the prepare/validate phases
13151        // above (every catalog, constraint, and claim read ran under a stable
13152        // schema); the publish below is guarded by the catalog-generation
13153        // check, exactly as before S1B-003. Release the barrier here — before
13154        // the sequencer — so a DDL waiting on its Exclusive hold may proceed
13155        // while this commit publishes (the generation check resolves that
13156        // race). Unique/sequence/FK claims stay held until the commit ends.
13157        if context.state.is_some() {
13158            self.lock_manager
13159                .release(txn_id, &crate::locks::LockKey::schema_barrier());
13160        }
13161        let added_runs: Vec<crate::wal::AddedRun> = spilled
13162            .iter()
13163            .map(|s| crate::wal::AddedRun {
13164                table_id: s.table_id,
13165                run_id: s.run_id,
13166                row_count: s.row_count,
13167                level: 0,
13168                min_row_id: s.min_rid,
13169                max_row_id: s.max_rid,
13170                content_hash: s.content_hash,
13171            })
13172            .collect();
13173        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
13174            hook();
13175        }
13176        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
13177        // Security mutations cannot overtake an authorized commit before its
13178        // commit marker is durable.
13179        let security_guard = self.security_coordinator.gate.read();
13180        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
13181            return Err(MongrelError::Conflict(
13182                "security policy changed during write".into(),
13183            ));
13184        }
13185        if spill_guard.is_some() {
13186            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
13187                hook();
13188            }
13189        }
13190        let commit_guard = self.commit_lock.lock();
13191        let catalog_generation_result = (|| {
13192            {
13193                let catalog = self.catalog.read();
13194                for table_id in prepared_table_handles.keys() {
13195                    let is_current = catalog.tables.iter().any(|entry| {
13196                        entry.table_id == *table_id
13197                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
13198                    });
13199                    if !is_current {
13200                        return Err(MongrelError::Conflict(format!(
13201                            "table {table_id} changed during transaction preparation"
13202                        )));
13203                    }
13204                }
13205                for (name, created_epoch) in &expected_external_generations {
13206                    let current = catalog
13207                        .external_tables
13208                        .iter()
13209                        .find(|entry| entry.name == *name)
13210                        .map(|entry| entry.created_epoch);
13211                    if current != Some(*created_epoch) {
13212                        return Err(MongrelError::Conflict(format!(
13213                            "external table {name:?} changed during transaction preparation"
13214                        )));
13215                    }
13216                }
13217                for (table_id, definition) in &prepared_materialized_views {
13218                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
13219                    if current != Some(*table_id) {
13220                        return Err(MongrelError::Conflict(format!(
13221                            "materialized view {:?} changed during transaction preparation",
13222                            definition.name
13223                        )));
13224                    }
13225                }
13226                if trigger_catalog_binding(&catalog) != trigger_binding {
13227                    return Err(MongrelError::Conflict(
13228                        "trigger or referenced table generation changed during transaction preparation"
13229                            .into(),
13230                    ));
13231                }
13232            }
13233            let tables = self.tables.read();
13234            for (table_id, prepared) in &prepared_table_handles {
13235                if !tables
13236                    .get(table_id)
13237                    .is_some_and(|current| current.ptr_eq(prepared))
13238                {
13239                    return Err(MongrelError::Conflict(format!(
13240                        "table {table_id} mount changed during transaction preparation"
13241                    )));
13242                }
13243            }
13244            Ok(())
13245        })();
13246        if let Err(error) = catalog_generation_result {
13247            drop(commit_guard);
13248            for (_, _, pending) in &prepared_external {
13249                let _ = std::fs::remove_file(pending);
13250            }
13251            return Err(error);
13252        }
13253        // The commit lock keeps the next epoch stable while logical spill
13254        // records are serialized. Build them before taking the shared WAL
13255        // lock, and cap their aggregate memory/WAL footprint.
13256        let new_epoch = self.epoch.assigned().next();
13257        // S1B-004 step 5 / P0.5: allocate the commit HLC under the sequencer
13258        // lock *before* durable row payloads are encoded so every Put /
13259        // SpilledRows version carries the same commit_ts as the receipt.
13260        // Spec §8.2: strictly greater than every participant read/write
13261        // timestamp captured at `begin`.
13262        let commit_ts = match context.read_ts {
13263            Some(read_ts) => self.hlc.commit_timestamp([read_ts]),
13264            None => self.hlc.now().map_err(|skew| {
13265                MongrelError::Other(format!(
13266                    "clock skew rejected commit timestamp allocation: {skew}"
13267                ))
13268            })?,
13269        };
13270        let mut spilled_wal_bytes = 0;
13271        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
13272        let spill_prepare = (|| {
13273            for run in &mut spilled {
13274                for row in &mut run.rows {
13275                    row.committed_epoch = new_epoch;
13276                    row.commit_ts = Some(commit_ts);
13277                }
13278                for rows in encode_spilled_row_chunks(
13279                    &run.rows,
13280                    &mut spilled_wal_bytes,
13281                    SPILLED_WAL_TOTAL_MAX_BYTES,
13282                    control,
13283                )? {
13284                    spilled_wal_records.push((
13285                        run.table_id,
13286                        Op::SpilledRows {
13287                            table_id: run.table_id,
13288                            rows,
13289                        },
13290                    ));
13291                }
13292            }
13293            Result::<()>::Ok(())
13294        })();
13295        if let Err(error) = spill_prepare {
13296            for (_, _, pending) in &prepared_external {
13297                let _ = std::fs::remove_file(pending);
13298            }
13299            return Err(error);
13300        }
13301        let (
13302            new_epoch,
13303            mut _epoch_guard,
13304            applies,
13305            committed_materialized_views,
13306            commit_seq,
13307            commit_ts,
13308        ) = {
13309            let mut wal = self.shared_wal.lock();
13310
13311            // Re-check only if the conflict index advanced since pre-validation
13312            // (bounded delta — spec §8.5, review fix #17). If the version is
13313            // unchanged, the pre-check result is still valid and the sequencer
13314            // does O(1) work regardless of write-set size.
13315            if self.conflicts.version() != pre_validate_version {
13316                if self.conflicts.conflicts(&write_keys, read_epoch) {
13317                    // Abort: this txn assigned no epoch yet. The prepared-run guard
13318                    // restores final run names to their pending paths on return.
13319                    drop(wal);
13320                    for (_, _, pending) in &prepared_external {
13321                        let _ = std::fs::remove_file(pending);
13322                    }
13323                    return Err(MongrelError::Conflict(
13324                        "write-write conflict (sequencer delta re-check)".into(),
13325                    ));
13326                }
13327                if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
13328                    // S1B-002 dangerous structure: a commit that landed between
13329                    // the pre-check and the sequencer invalidated a tracked
13330                    // read. Abort before assigning any epoch.
13331                    drop(wal);
13332                    for (_, _, pending) in &prepared_external {
13333                        let _ = std::fs::remove_file(pending);
13334                    }
13335                    return Err(MongrelError::SerializationFailure {
13336                        message:
13337                            "a concurrent commit invalidated this transaction's reads (sequencer re-check)"
13338                                .into(),
13339                    });
13340                }
13341            }
13342
13343            if let Some(control) = control {
13344                if let Err(error) = control.checkpoint() {
13345                    drop(wal);
13346                    for (_, _, pending) in &prepared_external {
13347                        let _ = std::fs::remove_file(pending);
13348                    }
13349                    return Err(error);
13350                }
13351            }
13352            let mut applies = Vec::<TableApplyBatch>::new();
13353            let mut apply_indexes = HashMap::<u64, usize>::new();
13354            let mut committed_materialized_views = Vec::new();
13355            let mut wal_records = spilled_wal_records;
13356
13357            let mut index = 0;
13358            while index < staging.len() {
13359                let table_id = staging[index].0;
13360                let handle = prepared_table_handles
13361                    .get(&table_id)
13362                    .cloned()
13363                    .ok_or_else(|| {
13364                        MongrelError::NotFound(format!("table {table_id} not prepared"))
13365                    })?;
13366                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
13367                    let index = applies.len();
13368                    applies.push(TableApplyBatch {
13369                        table_id,
13370                        handle,
13371                        ops: Vec::new(),
13372                    });
13373                    index
13374                });
13375
13376                // Skip puts for tables that were spilled — their data is in a
13377                // pending run, not in streamed Put records.
13378                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
13379                {
13380                    index += 1;
13381                    continue;
13382                }
13383
13384                match &staging[index].1 {
13385                    Staged::Put(_) => {
13386                        let mut rows = Vec::new();
13387                        while index < staging.len()
13388                            && staging[index].0 == table_id
13389                            && matches!(&staging[index].1, Staged::Put(_))
13390                        {
13391                            let mut row = prebuilt[index].take().ok_or_else(|| {
13392                                MongrelError::Other(
13393                                    "transaction prepare lost a prebuilt put row".into(),
13394                                )
13395                            })?;
13396                            row.committed_epoch = new_epoch;
13397                            row.commit_ts = Some(commit_ts);
13398                            rows.push(row);
13399                            index += 1;
13400                        }
13401                        let payload = bincode::serialize(&rows)
13402                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
13403                        wal_records.push((
13404                            table_id,
13405                            Op::Put {
13406                                table_id,
13407                                rows: payload,
13408                            },
13409                        ));
13410                        applies[batch_index].ops.push(StagedOp::Put(rows));
13411                    }
13412                    Staged::Delete(_) => {
13413                        let mut row_ids = Vec::new();
13414                        while index < staging.len()
13415                            && staging[index].0 == table_id
13416                            && matches!(&staging[index].1, Staged::Delete(_))
13417                        {
13418                            let Staged::Delete(row_id) = &staging[index].1 else {
13419                                return Err(MongrelError::Other(
13420                                    "transaction delete batch changed during WAL preparation"
13421                                        .into(),
13422                                ));
13423                            };
13424                            if let Some(before) = &delete_images[index] {
13425                                wal_records.push((
13426                                    table_id,
13427                                    Op::BeforeImage {
13428                                        table_id,
13429                                        row_id: *row_id,
13430                                        row: bincode::serialize(before).map_err(|error| {
13431                                            MongrelError::Other(format!(
13432                                                "before-image serialize: {error}"
13433                                            ))
13434                                        })?,
13435                                    },
13436                                ));
13437                            }
13438                            row_ids.push(*row_id);
13439                            index += 1;
13440                        }
13441                        wal_records.push((
13442                            table_id,
13443                            Op::Delete {
13444                                table_id,
13445                                row_ids: row_ids.clone(),
13446                            },
13447                        ));
13448                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
13449                    }
13450                    Staged::Truncate => {
13451                        wal_records.push((table_id, Op::TruncateTable { table_id }));
13452                        applies[batch_index].ops.push(StagedOp::Truncate);
13453                        index += 1;
13454                    }
13455                    Staged::Update { .. } => {
13456                        return Err(MongrelError::Other(
13457                            "transaction contains an unnormalized update at the sequencer".into(),
13458                        ));
13459                    }
13460                }
13461            }
13462
13463            for (name, state, _) in &prepared_external {
13464                wal_records.push((
13465                    EXTERNAL_TABLE_ID,
13466                    Op::ExternalTableState {
13467                        name: name.clone(),
13468                        state: state.clone(),
13469                    },
13470                ));
13471            }
13472
13473            for (table_id, definition) in &prepared_materialized_views {
13474                let mut definition = definition.clone();
13475                definition.last_refresh_epoch = new_epoch.0;
13476                wal_records.push((
13477                    *table_id,
13478                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
13479                        name: definition.name.clone(),
13480                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
13481                    }),
13482                ));
13483                committed_materialized_views.push(definition);
13484            }
13485            if !committed_materialized_views.is_empty() {
13486                let mut next_catalog = self.catalog.read().clone();
13487                for definition in &committed_materialized_views {
13488                    if let Some(existing) = next_catalog
13489                        .materialized_views
13490                        .iter_mut()
13491                        .find(|existing| existing.name == definition.name)
13492                    {
13493                        *existing = definition.clone();
13494                    } else {
13495                        next_catalog.materialized_views.push(definition.clone());
13496                    }
13497                }
13498                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13499                wal_records.push((
13500                    WAL_TABLE_ID,
13501                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
13502                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
13503                    }),
13504                ));
13505            }
13506
13507            if let Some(control) = control {
13508                if let Err(error) = control.checkpoint() {
13509                    drop(wal);
13510                    for (_, _, pending) in &prepared_external {
13511                        let _ = std::fs::remove_file(pending);
13512                    }
13513                    return Err(error);
13514                }
13515            }
13516            if let Some(before_commit) = before_commit.as_mut() {
13517                if let Err(error) = before_commit() {
13518                    drop(wal);
13519                    for (_, _, pending) in &prepared_external {
13520                        let _ = std::fs::remove_file(pending);
13521                    }
13522                    return Err(error);
13523                }
13524            }
13525
13526            let assigned_epoch = self.epoch.bump_assigned();
13527            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
13528            if assigned_epoch != new_epoch {
13529                for (_, _, pending) in &prepared_external {
13530                    let _ = std::fs::remove_file(pending);
13531                }
13532                return Err(MongrelError::Conflict(
13533                    "commit epoch changed while sequencer lock was held".into(),
13534                ));
13535            }
13536
13537            // S1B-004 step 6: enter commit-critical before the proposal can
13538            // become durable. The HLC commit_ts was allocated above under the
13539            // commit lock so every row payload already carries it.
13540            // FND-006: `txn.decision.before` fires immediately before the
13541            // durable commit decision (enter CommitCritical / WAL proposal).
13542            // A Fail aborts while still Preparing — nothing durable yet.
13543            if let Err(fault) = mongreldb_fault::inject("txn.decision.before") {
13544                for (_, _, pending) in &prepared_external {
13545                    let _ = std::fs::remove_file(pending);
13546                }
13547                return Err(crate::commit_log::fault_as_io(fault));
13548            }
13549            if let Some(state) = &context.state {
13550                state.enter_commit_critical();
13551            }
13552
13553            // From this point the outcome can become ambiguous. Keep prepared
13554            // spill files at the final names referenced by a possibly durable
13555            // commit marker; orphan cleanup is safe when the append did fail.
13556            prepared_run_links.disarm();
13557
13558            // FND-004 (spec §9.4): the commit log owns the append step for the
13559            // transaction command (records + commit marker). The commit path
13560            // proposes through it; durability and the receipt follow below.
13561            let commit_seq = self
13562                .standalone_commit_log
13563                .append_transaction(
13564                    &mut wal,
13565                    txn_id,
13566                    new_epoch,
13567                    commit_ts,
13568                    wal_records,
13569                    &added_runs,
13570                )
13571                .map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
13572
13573            // Record the conflict + assign the epoch under the WAL lock so commit
13574            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
13575            // moves out of this critical section to the group-commit coordinator
13576            // so concurrent committers share a single leader fsync.
13577            self.conflicts.record(&write_keys, new_epoch);
13578            (
13579                new_epoch,
13580                _epoch_guard,
13581                applies,
13582                committed_materialized_views,
13583                commit_seq,
13584                commit_ts,
13585            )
13586        };
13587        drop(commit_guard);
13588
13589        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
13590        let receipt =
13591            self.await_durable_commit_with_ts(txn_id, commit_seq, new_epoch, commit_ts)?;
13592        drop(security_guard);
13593
13594        // ── S1B-005: record the receipt against the reserved idempotency key
13595        // (durable before the caller can observe success), so a repeated key
13596        // with an identical request replays this receipt.
13597        if let Some(request) = &idempotency_request {
13598            if let Err(error) =
13599                self.idempotency
13600                    .complete(request, txn_id, new_epoch, receipt.commit_ts)
13601            {
13602                return Err(MongrelError::DurableCommit {
13603                    epoch: new_epoch.0,
13604                    message: format!("idempotency record was not durable: {error}"),
13605                });
13606            }
13607            if let Some(guard) = idempotency_guard.as_mut() {
13608                guard.disarm();
13609            }
13610        }
13611
13612        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
13613        let publish_result: Result<()> = {
13614            let mut first_error = None;
13615            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
13616            for run in &spilled {
13617                spilled_by_table.entry(run.table_id).or_default().push(run);
13618            }
13619            let mut modified_tables = Vec::with_capacity(applies.len());
13620            // Apply every table completely before any fallible manifest write.
13621            // The visible epoch remains unchanged until all tables are coherent.
13622            for batch in applies {
13623                #[cfg(test)]
13624                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13625                let mut t = batch.handle.lock();
13626                for op in batch.ops {
13627                    match op {
13628                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
13629                        StagedOp::Delete(row_ids) => {
13630                            for row_id in row_ids {
13631                                t.apply_delete_at(row_id, new_epoch, Some(commit_ts));
13632                            }
13633                        }
13634                        StagedOp::Truncate => t.apply_truncate(new_epoch),
13635                    }
13636                }
13637                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
13638                    for run in runs {
13639                        t.link_run(crate::manifest::RunRef {
13640                            run_id: run.run_id,
13641                            level: 0,
13642                            epoch_created: new_epoch.0,
13643                            row_count: run.row_count,
13644                        });
13645                        t.apply_run_metadata_prepared(&run.rows)?;
13646                        if truncated_tables.contains(&batch.table_id) {
13647                            // TRUNCATE + spilled puts fully describe this table at
13648                            // the commit epoch. Endorse the epoch so clean-reopen
13649                            // recovery does not replay the truncate over the
13650                            // already-linked replacement run.
13651                            t.set_flushed_epoch(new_epoch);
13652                        }
13653                    }
13654                }
13655                t.invalidate_pending_cache();
13656                drop(t);
13657                modified_tables.push(batch.handle);
13658            }
13659
13660            // Checkpoint only after every live table carries the durable state.
13661            // Continue after one checkpoint failure so runtime publication stays
13662            // all-or-nothing; WAL recovery repairs failed files on reopen.
13663            for handle in modified_tables {
13664                #[cfg(test)]
13665                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
13666                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
13667                    first_error.get_or_insert(error);
13668                }
13669            }
13670            for (name, _, pending) in &prepared_external {
13671                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
13672                    first_error.get_or_insert(error);
13673                }
13674            }
13675            if !committed_materialized_views.is_empty() {
13676                let mut next_catalog = self.catalog.read().clone();
13677                for definition in committed_materialized_views {
13678                    if let Some(existing) = next_catalog
13679                        .materialized_views
13680                        .iter_mut()
13681                        .find(|existing| existing.name == definition.name)
13682                    {
13683                        *existing = definition;
13684                    } else {
13685                        next_catalog.materialized_views.push(definition);
13686                    }
13687                }
13688                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13689                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
13690                    first_error.get_or_insert(error);
13691                }
13692            }
13693            match first_error {
13694                Some(error) => Err(error),
13695                None => Ok(()),
13696            }
13697        };
13698
13699        if has_changes {
13700            let _ = self.change_wake.send(());
13701        }
13702        self.finish_durable_publish(new_epoch, &mut _epoch_guard, &receipt, publish_result)?;
13703        // S1B-001: the commit is durable and published — record the receipt on
13704        // the formal state. (Post-publish errors above return `DurableCommit`
13705        // and deliberately leave the state `CommitCritical`.)
13706        if let Some(state) = &context.state {
13707            state.committed(receipt.clone());
13708        }
13709        // FND-006: `txn.decision.after` fires only after the decision is
13710        // durable and the Committed receipt is published. A Fail must not
13711        // undo the decision — surface as DurableCommit (post-fence).
13712        mongreldb_fault::inject("txn.decision.after").map_err(|fault| {
13713            MongrelError::DurableCommit {
13714                epoch: new_epoch.0,
13715                message: fault.to_string(),
13716            }
13717        })?;
13718        Ok((new_epoch, committed_row_ids))
13719    }
13720
13721    /// Build an HLC-authoritative snapshot at the current visible watermark
13722    /// (P0.5). Prefers the durable receipt HLC for the visible epoch; falls
13723    /// back to the live clock so HLC-stamped rows remain comparable.
13724    pub fn visible_snapshot(&self) -> Snapshot {
13725        self.snapshot_for_epoch(self.epoch.visible())
13726    }
13727
13728    /// Build an HLC-pinned snapshot at a specific commit epoch (P0.5).
13729    ///
13730    /// Prefer the durable ledger stamp for `epoch`, then the live HLC clock,
13731    /// then [`HlcTimestamp::MAX`] so HLC-stamped versions remain readable.
13732    pub fn snapshot_for_epoch(&self, epoch: Epoch) -> Snapshot {
13733        let commit_ts = self
13734            .commit_ts_for_epoch(epoch)
13735            .filter(|ts| *ts != mongreldb_types::hlc::HlcTimestamp::ZERO)
13736            .or_else(|| self.hlc.now().ok())
13737            .unwrap_or(mongreldb_types::hlc::HlcTimestamp::MAX);
13738        Snapshot::at_hlc(epoch, commit_ts)
13739    }
13740
13741    /// Register a read snapshot at the current visible epoch + HLC and return
13742    /// it with a guard that retains it for GC until dropped.
13743    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
13744        let snap = self.visible_snapshot();
13745        let g = self.snapshots.register(snap.epoch);
13746        (snap, g)
13747    }
13748
13749    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
13750    /// retention.
13751    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
13752        let snap = self.visible_snapshot();
13753        let g = self.snapshots.register_owned(snap.epoch);
13754        (snap, g)
13755    }
13756
13757    /// Configure a rolling history window measured in prior commit epochs.
13758    /// The first enable starts at the current epoch because earlier versions
13759    /// may already have been compacted. Increasing the window likewise cannot
13760    /// recreate history that fell outside the previous guarantee.
13761    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
13762        let _guard = self.ddl_lock.lock();
13763        let current = self.epoch.visible();
13764        let (old_epochs, old_start) = self.snapshots.history_config();
13765        let earliest_already_guaranteed = if old_epochs == 0 {
13766            current
13767        } else {
13768            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
13769        };
13770        let start = if epochs == 0 {
13771            current
13772        } else {
13773            earliest_already_guaranteed
13774        };
13775        let published = std::cell::Cell::new(false);
13776        let result = write_history_retention(&self.root, epochs, start, || {
13777            self.snapshots.configure_history(epochs, start);
13778            published.set(true);
13779        });
13780        match result {
13781            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
13782                epoch: current.0,
13783                message: format!("history-retention publication was not durable: {error}"),
13784            }),
13785            result => result,
13786        }
13787    }
13788
13789    pub fn history_retention_epochs(&self) -> u64 {
13790        self.snapshots.history_config().0
13791    }
13792
13793    pub fn earliest_retained_epoch(&self) -> Epoch {
13794        let current = self.epoch.visible();
13795        self.snapshots.history_floor(current).unwrap_or(current)
13796    }
13797
13798    /// Pin a guaranteed historical epoch for the lifetime of the returned
13799    /// guard. Rejects future epochs and epochs outside the configured window.
13800    /// When a durable HLC receipt exists for `epoch`, the snapshot is
13801    /// HLC-authoritative (`at_hlc`); otherwise it falls back to epoch-only.
13802    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
13803        let current = self.epoch.visible();
13804        if epoch > current {
13805            return Err(MongrelError::InvalidArgument(format!(
13806                "epoch {} is in the future; current epoch is {}",
13807                epoch.0, current.0
13808            )));
13809        }
13810        let earliest = self.earliest_retained_epoch();
13811        if epoch < earliest {
13812            return Err(MongrelError::InvalidArgument(format!(
13813                "epoch {} is no longer retained; earliest available epoch is {}",
13814                epoch.0, earliest.0
13815            )));
13816        }
13817        let guard = self.snapshots.register_owned(epoch);
13818        let snap = match self.commit_ts_for_epoch(epoch) {
13819            Some(commit_ts) => Snapshot::at_hlc(epoch, commit_ts),
13820            None => Snapshot::at(epoch),
13821        };
13822        Ok((snap, guard))
13823    }
13824
13825    /// Names of all live tables.
13826    pub fn table_names(&self) -> Vec<String> {
13827        self.catalog
13828            .read()
13829            .tables
13830            .iter()
13831            .filter(|t| matches!(t.state, TableState::Live))
13832            .map(|t| t.name.clone())
13833            .collect()
13834    }
13835
13836    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
13837    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
13838    /// reaped on the next open. Call this as the last action before a
13839    /// short-lived process (CLI, one-shot script) exits. The daemon does not
13840    /// need this — its background auto-compactor handles run management.
13841    pub fn close(&self) -> Result<()> {
13842        for name in self.table_names() {
13843            if let Ok(handle) = self.table(&name) {
13844                if let Err(e) = handle.lock().close() {
13845                    eprintln!("[close] flush failed for {name}: {e}");
13846                }
13847            }
13848        }
13849        Ok(())
13850    }
13851
13852    /// Compact every mounted table: merge all sorted runs into one clean run
13853    /// so query cost stays flat (single-run fast path) instead of growing
13854    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
13855    /// rows to reclaim. Each table
13856    /// is locked individually for its own compaction; snapshot retention is
13857    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
13858    pub fn compact(&self) -> Result<(usize, usize)> {
13859        self.require(&crate::auth::Permission::Ddl)?;
13860        // S1A-004: admit the compaction pass as one core operation.
13861        let _operation = self.admit_operation()?;
13862        let mut compacted = 0;
13863        let mut skipped = 0;
13864        for name in self.table_names() {
13865            let Ok(handle) = self.table(&name) else {
13866                continue;
13867            };
13868            {
13869                let mut t = handle.lock();
13870                let before = t.run_count();
13871                if before < 2 && !t.should_compact() {
13872                    skipped += 1;
13873                    continue;
13874                }
13875                match t.compact() {
13876                    Ok(()) => {
13877                        let after = t.run_count();
13878                        compacted += 1;
13879                        eprintln!("[compact] {name}: {before} -> {after} runs");
13880                    }
13881                    Err(e) => {
13882                        eprintln!("[compact] {name}: compaction failed: {e}");
13883                        skipped += 1;
13884                    }
13885                }
13886            }
13887        }
13888        Ok((compacted, skipped))
13889    }
13890
13891    /// Compact a single table by name. Returns `Ok(true)` if it was
13892    /// compacted, `Ok(false)` if skipped (< 2 runs).
13893    pub fn compact_table(&self, name: &str) -> Result<bool> {
13894        self.require(&crate::auth::Permission::Ddl)?;
13895        let handle = self.table(name)?;
13896        let mut t = handle.lock();
13897        let before = t.run_count();
13898        if before < 2 {
13899            return Ok(false);
13900        }
13901        t.compact()?;
13902        Ok(t.run_count() < before)
13903    }
13904
13905    /// Look up a live table by name.
13906    pub fn table(&self, name: &str) -> Result<TableHandle> {
13907        self.ensure_owner_process()?;
13908        let cat = self.catalog.read();
13909        let entry = cat
13910            .live(name)
13911            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13912        let id = entry.table_id;
13913        drop(cat);
13914        self.tables
13915            .read()
13916            .get(&id)
13917            .cloned()
13918            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
13919    }
13920
13921    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
13922    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
13923    pub fn has_ttl_tables(&self) -> bool {
13924        self.tables
13925            .read()
13926            .values()
13927            .any(|table| table.lock().ttl().is_some())
13928    }
13929
13930    /// Resolve a live table id → mounted handle (used by the constraint
13931    /// validation pass and other id-qualified internal paths).
13932    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
13933        self.tables
13934            .read()
13935            .get(&id)
13936            .cloned()
13937            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
13938    }
13939
13940    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
13941    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
13942    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
13943    /// allocation; explicit values merely advance the counter (an
13944    /// order-insensitive `max` under the table lock) and must not serialize.
13945    pub(crate) fn table_auto_inc_would_allocate(
13946        &self,
13947        table_id: u64,
13948        cells: &[(u16, Value)],
13949    ) -> bool {
13950        let catalog = self.catalog.read();
13951        let Some(entry) = catalog
13952            .tables
13953            .iter()
13954            .find(|entry| entry.table_id == table_id)
13955        else {
13956            return false;
13957        };
13958        let Some(column) = entry.schema.auto_increment_column() else {
13959            return false;
13960        };
13961        matches!(
13962            cells.iter().find(|(id, _)| *id == column.id),
13963            None | Some((_, Value::Null))
13964        )
13965    }
13966
13967    /// Create a new table. The DDL is first logged to the shared WAL
13968    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
13969    /// BEFORE the in-memory catalog and table map are mutated; the catalog
13970    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
13971    /// that sees a stale catalog still recovers the table by replaying the Ddl.
13972    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
13973        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
13974            return Err(MongrelError::InvalidArgument(format!(
13975                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
13976            )));
13977        }
13978        self.create_table_with_state(name, schema, TableState::Live)
13979    }
13980
13981    /// Create a durable but non-queryable CTAS build table.
13982    #[doc(hidden)]
13983    pub fn create_building_table(
13984        &self,
13985        build_name: &str,
13986        intended_name: &str,
13987        query_id: &str,
13988        schema: Schema,
13989    ) -> Result<u64> {
13990        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13991            || intended_name.is_empty()
13992            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13993            || query_id.is_empty()
13994        {
13995            return Err(MongrelError::InvalidArgument(
13996                "invalid CTAS building-table identity".into(),
13997            ));
13998        }
13999        self.create_table_with_state(
14000            build_name,
14001            schema,
14002            TableState::Building {
14003                intended_name: intended_name.to_string(),
14004                query_id: query_id.to_string(),
14005                created_at_unix_nanos: current_unix_nanos(),
14006                replaces_table_id: None,
14007            },
14008        )
14009    }
14010
14011    /// Create a hidden schema-rebuild table while the intended target remains
14012    /// live. Publication later validates that the same target is still live.
14013    #[doc(hidden)]
14014    pub fn create_rebuilding_table(
14015        &self,
14016        build_name: &str,
14017        intended_name: &str,
14018        query_id: &str,
14019        schema: Schema,
14020    ) -> Result<u64> {
14021        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14022            || intended_name.is_empty()
14023            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14024            || query_id.is_empty()
14025        {
14026            return Err(MongrelError::InvalidArgument(
14027                "invalid rebuilding-table identity".into(),
14028            ));
14029        }
14030        let replaces_table_id = self
14031            .catalog
14032            .read()
14033            .live(intended_name)
14034            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
14035            .table_id;
14036        self.create_table_with_state(
14037            build_name,
14038            schema,
14039            TableState::Building {
14040                intended_name: intended_name.to_string(),
14041                query_id: query_id.to_string(),
14042                created_at_unix_nanos: current_unix_nanos(),
14043                replaces_table_id: Some(replaces_table_id),
14044            },
14045        )
14046    }
14047
14048    fn create_table_with_state(
14049        &self,
14050        name: &str,
14051        schema: Schema,
14052        state: TableState,
14053    ) -> Result<u64> {
14054        use crate::wal::DdlOp;
14055        use std::sync::atomic::Ordering;
14056
14057        self.require(&crate::auth::Permission::Ddl)?;
14058        if self.poisoned.load(Ordering::Relaxed) {
14059            return Err(MongrelError::Other(
14060                "database poisoned by fsync error".into(),
14061            ));
14062        }
14063        // S1A-004: admit the DDL as one core operation.
14064        let _operation = self.admit_operation()?;
14065        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14066        let _g = self.ddl_lock.lock();
14067        let _security_write = self.security_write()?;
14068        self.require(&crate::auth::Permission::Ddl)?;
14069        {
14070            let cat = self.catalog.read();
14071            match &state {
14072                TableState::Live => {
14073                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
14074                        return Err(MongrelError::InvalidArgument(format!(
14075                            "table {name:?} already exists or is being built"
14076                        )));
14077                    }
14078                }
14079                TableState::Building {
14080                    intended_name,
14081                    replaces_table_id,
14082                    ..
14083                } => {
14084                    let target_matches = match replaces_table_id {
14085                        Some(table_id) => cat
14086                            .live(intended_name)
14087                            .is_some_and(|entry| entry.table_id == *table_id),
14088                        None => cat.live(intended_name).is_none(),
14089                    };
14090                    if !target_matches || cat.building_for(intended_name).is_some() {
14091                        return Err(MongrelError::InvalidArgument(format!(
14092                            "table {intended_name:?} changed or is already being built"
14093                        )));
14094                    }
14095                    if cat.building(name).is_some() {
14096                        return Err(MongrelError::InvalidArgument(format!(
14097                            "building table {name:?} already exists"
14098                        )));
14099                    }
14100                }
14101                TableState::Dropped { .. } => {
14102                    return Err(MongrelError::InvalidArgument(
14103                        "cannot create a dropped table".into(),
14104                    ));
14105                }
14106            }
14107        }
14108
14109        // Allocate id + epoch + txn id under the commit lock so the DDL commit
14110        // is serialized with data commits (in-order publish). Live creates
14111        // draw their table id from the routed catalog command below (S1F-001);
14112        // CTAS building-table creates keep the legacy direct allocation.
14113        let commit_lock = Arc::clone(&self.commit_lock);
14114        let _c = commit_lock.lock();
14115        let live_create = matches!(state, TableState::Live);
14116        let legacy_table_id = if live_create {
14117            None
14118        } else {
14119            let mut cat = self.catalog.write();
14120            let id = cat.next_table_id;
14121            cat.next_table_id = id
14122                .checked_add(1)
14123                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
14124            Some(id)
14125        };
14126        let epoch = self.epoch.bump_assigned();
14127        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14128        let txn_id = self.alloc_txn_id()?;
14129
14130        let mut schema = schema;
14131        if let Some(table_id) = legacy_table_id {
14132            // Stamp the schema_id with the unique table_id so every table in
14133            // the database has a distinct schema_id (caller-provided values
14134            // are ignored to prevent collisions).
14135            schema.schema_id = table_id;
14136            // Defense in depth: reject an invalid schema BEFORE any durable
14137            // side-effect. `Table::create_in` re-validates, but by then the
14138            // DDL has already been appended to the shared WAL; a failing
14139            // create_in would leave a dangling entry that `recover_ddl_from_wal`
14140            // replays without re-validating, corrupting the catalog on reopen.
14141            // Validating here keeps the WAL free of schemas that can never be
14142            // opened.
14143            schema.validate_auto_increment()?;
14144            schema.validate_defaults()?;
14145            schema.validate_ai()?;
14146            for index in &schema.indexes {
14147                index.validate_options()?;
14148            }
14149            for constraint in &schema.constraints.checks {
14150                constraint.expr.validate()?;
14151            }
14152        }
14153
14154        let mut next_catalog = self.catalog.read().clone();
14155        let (table_id, schema) = match legacy_table_id {
14156            Some(table_id) => {
14157                next_catalog.tables.push(CatalogEntry {
14158                    table_id,
14159                    name: name.to_string(),
14160                    schema: schema.clone(),
14161                    state: state.clone(),
14162                    created_epoch: epoch.0,
14163                });
14164                (table_id, schema)
14165            }
14166            None => {
14167                // S1F-001: the mutation is a versioned catalog command —
14168                // schema validation (the same five validators), table-id
14169                // allocation, and schema_id stamping all resolve inside it.
14170                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
14171                    name: name.to_string(),
14172                    schema,
14173                    created_epoch: epoch.0,
14174                };
14175                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
14176                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
14177                    return Err(MongrelError::Other(
14178                        "CreateTable resolved to an unexpected catalog delta".into(),
14179                    ));
14180                };
14181                (entry.table_id, entry.schema)
14182            }
14183        };
14184        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14185
14186        // Build the complete mounted table before its DDL can become durable.
14187        // Any failure removes the unpublished directory and abandons the epoch.
14188        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
14189        let canonical_tdir = self.root.join(&table_relative);
14190        let table_root = Arc::new(
14191            self.durable_root
14192                .create_directory_all_pinned(&table_relative)?,
14193        );
14194        let tdir = table_root.io_path()?;
14195        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
14196        let ctx = SharedCtx {
14197            root_guard: Some(table_root),
14198            epoch: Arc::clone(&self.epoch),
14199            page_cache: Arc::clone(&self.page_cache),
14200            decoded_cache: Arc::clone(&self.decoded_cache),
14201            snapshots: Arc::clone(&self.snapshots),
14202            kek: self.kek.clone(),
14203            commit_lock: Arc::clone(&self.commit_lock),
14204            shared: Some(crate::engine::SharedWalCtx {
14205                wal: Arc::clone(&self.shared_wal),
14206                group: Arc::clone(&self.group),
14207                poisoned: Arc::clone(&self.poisoned),
14208                txn_ids: Arc::clone(&self.next_txn_id),
14209                change_wake: self.change_wake.clone(),
14210                lifecycle: Arc::clone(&self.lifecycle),
14211                hlc: Arc::clone(&self.hlc),
14212            }),
14213            table_name: Some(name.to_string()),
14214            auth: self.table_auth_checker(),
14215            read_only: self.read_only,
14216        };
14217        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
14218
14219        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
14220        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
14221        let schema_json = DdlOp::encode_schema(&schema)?;
14222        let ddl = match &state {
14223            TableState::Live => DdlOp::CreateTable {
14224                table_id,
14225                name: name.to_string(),
14226                schema_json,
14227            },
14228            TableState::Building {
14229                intended_name,
14230                query_id,
14231                created_at_unix_nanos,
14232                replaces_table_id,
14233            } => match replaces_table_id {
14234                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
14235                    table_id,
14236                    build_name: name.to_string(),
14237                    intended_name: intended_name.clone(),
14238                    query_id: query_id.clone(),
14239                    created_at_unix_nanos: *created_at_unix_nanos,
14240                    replaces_table_id: *replaces_table_id,
14241                    schema_json,
14242                },
14243                None => DdlOp::CreateBuildingTable {
14244                    table_id,
14245                    build_name: name.to_string(),
14246                    intended_name: intended_name.clone(),
14247                    query_id: query_id.clone(),
14248                    created_at_unix_nanos: *created_at_unix_nanos,
14249                    schema_json,
14250                },
14251            },
14252            TableState::Dropped { .. } => {
14253                return Err(MongrelError::InvalidArgument(
14254                    "cannot create a table in dropped state".into(),
14255                ));
14256            }
14257        };
14258        let commit_seq = {
14259            let mut wal = self.shared_wal.lock();
14260            let append: Result<u64> = (|| {
14261                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14262                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14263                wal.append_commit(txn_id, epoch, &[])
14264            })();
14265            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14266        };
14267        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14268        pending_table_dir.disarm();
14269
14270        // Publish the mounted table and catalog in memory even if the catalog
14271        // checkpoint fails after the WAL commit.
14272        self.tables
14273            .write()
14274            .insert(table_id, TableHandle::new(table));
14275        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14276        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14277        Ok(table_id)
14278    }
14279
14280    /// Logically drop a table, logging the DDL through the shared WAL first.
14281    pub fn drop_table(&self, name: &str) -> Result<()> {
14282        self.drop_table_with_epoch(name).map(|_| ())
14283    }
14284
14285    /// Logically drop a table and return the exact publication epoch.
14286    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
14287        self.drop_table_with_state(name, false, None)
14288    }
14289
14290    pub fn drop_table_with_epoch_controlled<F>(
14291        &self,
14292        name: &str,
14293        mut before_commit: F,
14294    ) -> Result<Epoch>
14295    where
14296        F: FnMut() -> Result<()>,
14297    {
14298        self.drop_table_with_state(name, false, Some(&mut before_commit))
14299    }
14300
14301    /// Discard an unpublished CTAS build.
14302    #[doc(hidden)]
14303    pub fn discard_building_table(&self, name: &str) -> Result<()> {
14304        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14305            return Err(MongrelError::InvalidArgument(
14306                "not a CTAS building table".into(),
14307            ));
14308        }
14309        self.drop_table_with_state(name, true, None).map(|_| ())
14310    }
14311
14312    fn drop_table_with_state(
14313        &self,
14314        name: &str,
14315        building: bool,
14316        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14317    ) -> Result<Epoch> {
14318        use crate::wal::DdlOp;
14319        use std::sync::atomic::Ordering;
14320
14321        self.require(&crate::auth::Permission::Ddl)?;
14322        if self.poisoned.load(Ordering::Relaxed) {
14323            return Err(MongrelError::Other(
14324                "database poisoned by fsync error".into(),
14325            ));
14326        }
14327        // S1A-004: admit the DDL as one core operation.
14328        let _operation = self.admit_operation()?;
14329        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14330        let _g = self.ddl_lock.lock();
14331        let _security_write = self.security_write()?;
14332        self.require(&crate::auth::Permission::Ddl)?;
14333        let table_id = {
14334            let cat = self.catalog.read();
14335            if building {
14336                cat.building(name)
14337            } else {
14338                cat.live(name)
14339            }
14340            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
14341            .table_id
14342        };
14343
14344        let commit_lock = Arc::clone(&self.commit_lock);
14345        let _c = commit_lock.lock();
14346        let epoch = self.epoch.bump_assigned();
14347        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14348        let txn_id = self.alloc_txn_id()?;
14349        let mut next_catalog = self.catalog.read().clone();
14350        if building {
14351            // CTAS building-table discards stay on the legacy mutation: the
14352            // command model's `DropTable` resolves live tables only.
14353            let entry = next_catalog
14354                .tables
14355                .iter_mut()
14356                .find(|t| t.table_id == table_id)
14357                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14358            entry.state = TableState::Dropped { at_epoch: epoch.0 };
14359            next_catalog.triggers.retain(|trigger| {
14360                !matches!(
14361                    &trigger.trigger.target,
14362                    TriggerTarget::Table(target) if target == name
14363                )
14364            });
14365            next_catalog
14366                .materialized_views
14367                .retain(|definition| definition.name != name);
14368            next_catalog
14369                .security
14370                .rls_tables
14371                .retain(|table| table != name);
14372            next_catalog
14373                .security
14374                .policies
14375                .retain(|policy| policy.table != name);
14376            next_catalog
14377                .security
14378                .masks
14379                .retain(|mask| mask.table != name);
14380            for role in &mut next_catalog.roles {
14381                role.permissions
14382                    .retain(|permission| permission_table(permission) != Some(name));
14383            }
14384            advance_security_version(&mut next_catalog)?;
14385        } else {
14386            // S1F-001: a plain live-table drop is a versioned catalog command
14387            // (the delta mirrors the legacy mutation above, cascading
14388            // triggers, materialized views, and table-scoped security state).
14389            let command = crate::catalog_cmds::CatalogCommand::DropTable {
14390                name: name.to_string(),
14391                at_epoch: epoch.0,
14392            };
14393            self.apply_catalog_command_to(&mut next_catalog, command)?;
14394        }
14395        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14396        let commit_seq = {
14397            let mut wal = self.shared_wal.lock();
14398            if let Some(before_commit) = before_commit {
14399                before_commit()?;
14400            }
14401            let append: Result<u64> = (|| {
14402                wal.append(
14403                    txn_id,
14404                    table_id,
14405                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
14406                )?;
14407                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14408                wal.append_commit(txn_id, epoch, &[])
14409            })();
14410            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14411        };
14412        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14413
14414        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14415        self.tables.write().remove(&table_id);
14416        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14417        Ok(epoch)
14418    }
14419
14420    /// Rename a live table. `name` must exist and `new_name` must not collide
14421    /// with any live table; both checks run under `ddl_lock` so they are atomic
14422    /// with the rename and with concurrent `create_table` existence checks (no
14423    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
14424    /// side-effects. The rename is logged to the shared WAL as
14425    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
14426    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
14427    /// the in-memory object does not move — only the catalog name changes).
14428    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
14429        self.rename_table_with_epoch(name, new_name).map(|_| ())
14430    }
14431
14432    /// Rename a table and return its exact publication epoch.
14433    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
14434        self.rename_table_with_epoch_inner(name, new_name, None)
14435    }
14436
14437    pub fn rename_table_with_epoch_controlled<F>(
14438        &self,
14439        name: &str,
14440        new_name: &str,
14441        mut before_commit: F,
14442    ) -> Result<Epoch>
14443    where
14444        F: FnMut() -> Result<()>,
14445    {
14446        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
14447    }
14448
14449    fn rename_table_with_epoch_inner(
14450        &self,
14451        name: &str,
14452        new_name: &str,
14453        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14454    ) -> Result<Epoch> {
14455        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14456            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14457        {
14458            return Err(MongrelError::InvalidArgument(
14459                "the CTAS building-table namespace is reserved".into(),
14460            ));
14461        }
14462        self.rename_table_with_state(name, new_name, false, None, before_commit)
14463    }
14464
14465    /// Atomically publish a hidden CTAS build under its intended live name.
14466    #[doc(hidden)]
14467    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14468        self.publish_building_table_inner(build_name, new_name, None)
14469    }
14470
14471    #[doc(hidden)]
14472    pub fn publish_building_table_controlled<F>(
14473        &self,
14474        build_name: &str,
14475        new_name: &str,
14476        mut before_commit: F,
14477    ) -> Result<Epoch>
14478    where
14479        F: FnMut() -> Result<()>,
14480    {
14481        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
14482    }
14483
14484    fn publish_building_table_inner(
14485        &self,
14486        build_name: &str,
14487        new_name: &str,
14488        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14489    ) -> Result<Epoch> {
14490        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14491            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14492        {
14493            return Err(MongrelError::InvalidArgument(
14494                "invalid CTAS publish identity".into(),
14495            ));
14496        }
14497        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
14498    }
14499
14500    /// Atomically publish a hidden build and its materialized-view definition.
14501    #[doc(hidden)]
14502    pub fn publish_materialized_building_table(
14503        &self,
14504        build_name: &str,
14505        new_name: &str,
14506        definition: crate::catalog::MaterializedViewEntry,
14507    ) -> Result<Epoch> {
14508        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
14509    }
14510
14511    #[doc(hidden)]
14512    pub fn publish_materialized_building_table_controlled<F>(
14513        &self,
14514        build_name: &str,
14515        new_name: &str,
14516        definition: crate::catalog::MaterializedViewEntry,
14517        mut before_commit: F,
14518    ) -> Result<Epoch>
14519    where
14520        F: FnMut() -> Result<()>,
14521    {
14522        self.publish_materialized_building_table_inner(
14523            build_name,
14524            new_name,
14525            definition,
14526            Some(&mut before_commit),
14527        )
14528    }
14529
14530    fn publish_materialized_building_table_inner(
14531        &self,
14532        build_name: &str,
14533        new_name: &str,
14534        definition: crate::catalog::MaterializedViewEntry,
14535        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14536    ) -> Result<Epoch> {
14537        if definition.name != new_name || definition.query.trim().is_empty() {
14538            return Err(MongrelError::InvalidArgument(
14539                "invalid materialized-view publication".into(),
14540            ));
14541        }
14542        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
14543    }
14544
14545    /// Atomically replace a still-live table with its completed hidden rebuild.
14546    #[doc(hidden)]
14547    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14548        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
14549    }
14550
14551    #[doc(hidden)]
14552    pub fn publish_rebuilding_table_controlled<F>(
14553        &self,
14554        build_name: &str,
14555        new_name: &str,
14556        mut before_commit: F,
14557    ) -> Result<Epoch>
14558    where
14559        F: FnMut() -> Result<()>,
14560    {
14561        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
14562    }
14563
14564    /// Atomically replace a live materialized-view table and its definition.
14565    #[doc(hidden)]
14566    pub fn publish_materialized_rebuilding_table_controlled<F>(
14567        &self,
14568        build_name: &str,
14569        new_name: &str,
14570        definition: crate::catalog::MaterializedViewEntry,
14571        mut before_commit: F,
14572    ) -> Result<Epoch>
14573    where
14574        F: FnMut() -> Result<()>,
14575    {
14576        self.publish_rebuilding_table_inner(
14577            build_name,
14578            new_name,
14579            Some(definition),
14580            Some(&mut before_commit),
14581        )
14582    }
14583
14584    fn publish_rebuilding_table_inner(
14585        &self,
14586        build_name: &str,
14587        new_name: &str,
14588        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14589        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14590    ) -> Result<Epoch> {
14591        use crate::wal::DdlOp;
14592
14593        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14594            || new_name.is_empty()
14595            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14596        {
14597            return Err(MongrelError::InvalidArgument(
14598                "invalid rebuilding-table publish identity".into(),
14599            ));
14600        }
14601        if materialized_view.as_ref().is_some_and(|definition| {
14602            definition.name != new_name || definition.query.trim().is_empty()
14603        }) {
14604            return Err(MongrelError::InvalidArgument(
14605                "invalid materialized-view replacement".into(),
14606            ));
14607        }
14608        self.require(&crate::auth::Permission::Ddl)?;
14609        if self.poisoned.load(Ordering::Relaxed) {
14610            return Err(MongrelError::Other(
14611                "database poisoned by fsync error".into(),
14612            ));
14613        }
14614        // S1A-004: admit the DDL as one core operation.
14615        let _operation = self.admit_operation()?;
14616        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14617        let _ddl = self.ddl_lock.lock();
14618        let _security_write = self.security_write()?;
14619        let (table_id, replaced_table_id) = {
14620            let catalog = self.catalog.read();
14621            let build = catalog.building(build_name).ok_or_else(|| {
14622                MongrelError::NotFound(format!("building table {build_name:?} not found"))
14623            })?;
14624            let replaced_table_id = match &build.state {
14625                TableState::Building {
14626                    intended_name,
14627                    replaces_table_id: Some(replaced_table_id),
14628                    ..
14629                } if intended_name == new_name => *replaced_table_id,
14630                _ => {
14631                    return Err(MongrelError::InvalidArgument(format!(
14632                        "building table {build_name:?} is not a replacement for {new_name:?}"
14633                    )))
14634                }
14635            };
14636            if catalog
14637                .live(new_name)
14638                .is_none_or(|entry| entry.table_id != replaced_table_id)
14639            {
14640                return Err(MongrelError::Conflict(format!(
14641                    "table {new_name:?} changed while its replacement was built"
14642                )));
14643            }
14644            (build.table_id, replaced_table_id)
14645        };
14646
14647        let _commit = self.commit_lock.lock();
14648        let epoch = self.epoch.assigned().next();
14649        let txn_id = self.alloc_txn_id()?;
14650        let mut next_catalog = self.catalog.read().clone();
14651        apply_rebuilding_publish(
14652            &mut next_catalog,
14653            table_id,
14654            replaced_table_id,
14655            new_name,
14656            epoch.0,
14657        )?;
14658        if let Some(definition) = materialized_view.as_mut() {
14659            definition.last_refresh_epoch = epoch.0;
14660        }
14661        let materialized_view_json = materialized_view
14662            .as_ref()
14663            .map(DdlOp::encode_materialized_view)
14664            .transpose()?;
14665        if let Some(definition) = materialized_view {
14666            if let Some(existing) = next_catalog
14667                .materialized_views
14668                .iter_mut()
14669                .find(|existing| existing.name == definition.name)
14670            {
14671                *existing = definition;
14672            } else {
14673                next_catalog.materialized_views.push(definition);
14674            }
14675        }
14676        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14677        if let Some(before_commit) = before_commit {
14678            before_commit()?;
14679        }
14680        let assigned_epoch = self.epoch.bump_assigned();
14681        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
14682        if assigned_epoch != epoch {
14683            return Err(MongrelError::Conflict(
14684                "commit epoch changed while sequencer lock was held".into(),
14685            ));
14686        }
14687        let commit_seq = {
14688            let mut wal = self.shared_wal.lock();
14689            let append: Result<u64> = (|| {
14690                wal.append(
14691                    txn_id,
14692                    table_id,
14693                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
14694                        table_id,
14695                        replaced_table_id,
14696                        new_name: new_name.to_string(),
14697                    }),
14698                )?;
14699                if let Some(definition_json) = materialized_view_json {
14700                    wal.append(
14701                        txn_id,
14702                        table_id,
14703                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14704                            name: new_name.to_string(),
14705                            definition_json,
14706                        }),
14707                    )?;
14708                }
14709                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14710                wal.append_commit(txn_id, epoch, &[])
14711            })();
14712            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14713        };
14714        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14715
14716        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14717        self.tables.write().remove(&replaced_table_id);
14718        if let Some(table) = self.tables.read().get(&table_id) {
14719            table.lock().set_catalog_name(new_name.to_string());
14720        }
14721        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
14722        Ok(epoch)
14723    }
14724
14725    fn rename_table_with_state(
14726        &self,
14727        name: &str,
14728        new_name: &str,
14729        building: bool,
14730        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14731        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14732    ) -> Result<Epoch> {
14733        use crate::wal::DdlOp;
14734        use std::sync::atomic::Ordering;
14735
14736        self.require(&crate::auth::Permission::Ddl)?;
14737        if self.poisoned.load(Ordering::Relaxed) {
14738            return Err(MongrelError::Other(
14739                "database poisoned by fsync error".into(),
14740            ));
14741        }
14742
14743        // A no-op rename short-circuits before any locking, so it can never
14744        // trip the "target already exists" check (the source *is* that name).
14745        if name == new_name {
14746            return Ok(self.visible_epoch());
14747        }
14748        if new_name.is_empty() {
14749            return Err(MongrelError::InvalidArgument(
14750                "rename_table: new name must not be empty".into(),
14751            ));
14752        }
14753        // S1A-004: admit the DDL as one core operation.
14754        let _operation = self.admit_operation()?;
14755        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14756        let _g = self.ddl_lock.lock();
14757        let _security_write = self.security_write()?;
14758        self.require(&crate::auth::Permission::Ddl)?;
14759        let table_id = {
14760            let cat = self.catalog.read();
14761            let src = if building {
14762                cat.building(name)
14763            } else {
14764                cat.live(name)
14765            }
14766            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14767            if building
14768                && !matches!(
14769                    &src.state,
14770                    TableState::Building { intended_name, .. } if intended_name == new_name
14771                )
14772            {
14773                return Err(MongrelError::InvalidArgument(format!(
14774                    "building table {name:?} is not reserved for {new_name:?}"
14775                )));
14776            }
14777            // Target must be free. Checked under ddl_lock, which every other
14778            // DDL (create/rename/drop) also holds, so a concurrent operation
14779            // cannot claim `new_name` between this check and the catalog write.
14780            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
14781                return Err(MongrelError::InvalidArgument(format!(
14782                    "rename_table: a table named {new_name:?} already exists"
14783                )));
14784            }
14785            src.table_id
14786        };
14787
14788        let commit_lock = Arc::clone(&self.commit_lock);
14789        let _c = commit_lock.lock();
14790        let epoch = self.epoch.bump_assigned();
14791        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14792        let txn_id = self.alloc_txn_id()?;
14793        if let Some(definition) = materialized_view.as_mut() {
14794            definition.last_refresh_epoch = epoch.0;
14795        }
14796        let materialized_view_json = materialized_view
14797            .as_ref()
14798            .map(DdlOp::encode_materialized_view)
14799            .transpose()?;
14800        let mut next_catalog = self.catalog.read().clone();
14801        if building {
14802            // CTAS building-table publishes stay on the legacy mutation: the
14803            // command model's `RenameTable` resolves live tables only.
14804            let entry = next_catalog
14805                .tables
14806                .iter_mut()
14807                .find(|t| t.table_id == table_id)
14808                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14809            entry.name = new_name.to_string();
14810            entry.state = TableState::Live;
14811            for trigger in &mut next_catalog.triggers {
14812                if matches!(
14813                    &trigger.trigger.target,
14814                    TriggerTarget::Table(target) if target == name
14815                ) {
14816                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
14817                }
14818            }
14819            if let Some(definition) = next_catalog
14820                .materialized_views
14821                .iter_mut()
14822                .find(|definition| definition.name == name)
14823            {
14824                definition.name = new_name.to_string();
14825            }
14826            if let Some(definition) = materialized_view.take() {
14827                next_catalog.materialized_views.push(definition);
14828            }
14829            for table in &mut next_catalog.security.rls_tables {
14830                if table == name {
14831                    *table = new_name.to_string();
14832                }
14833            }
14834            for policy in &mut next_catalog.security.policies {
14835                if policy.table == name {
14836                    policy.table = new_name.to_string();
14837                }
14838            }
14839            for mask in &mut next_catalog.security.masks {
14840                if mask.table == name {
14841                    mask.table = new_name.to_string();
14842                }
14843            }
14844            for role in &mut next_catalog.roles {
14845                for permission in &mut role.permissions {
14846                    rename_permission_table(permission, name, new_name);
14847                }
14848            }
14849            advance_security_version(&mut next_catalog)?;
14850        } else {
14851            // S1F-001: a plain live-table rename is a versioned catalog
14852            // command (the delta mirrors the legacy mutation above, including
14853            // trigger retargets and table-scoped security state).
14854            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
14855                name: name.to_string(),
14856                new_name: new_name.to_string(),
14857                at_epoch: epoch.0,
14858            };
14859            self.apply_catalog_command_to(&mut next_catalog, command)?;
14860        }
14861        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14862        let ddl = if building {
14863            DdlOp::PublishBuildingTable {
14864                table_id,
14865                new_name: new_name.to_string(),
14866            }
14867        } else {
14868            DdlOp::RenameTable {
14869                table_id,
14870                new_name: new_name.to_string(),
14871            }
14872        };
14873        let commit_seq = {
14874            let mut wal = self.shared_wal.lock();
14875            if let Some(before_commit) = before_commit {
14876                before_commit()?;
14877            }
14878            let append: Result<u64> = (|| {
14879                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14880                if let Some(definition_json) = materialized_view_json {
14881                    wal.append(
14882                        txn_id,
14883                        table_id,
14884                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14885                            name: new_name.to_string(),
14886                            definition_json,
14887                        }),
14888                    )?;
14889                }
14890                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14891                wal.append_commit(txn_id, epoch, &[])
14892            })();
14893            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14894        };
14895        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14896
14897        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14898        // The in-memory table object is keyed by table_id, not name, so it does
14899        // not move and live TableHandles remain valid.
14900        if let Some(table) = self.tables.read().get(&table_id) {
14901            table.lock().set_catalog_name(new_name.to_string());
14902        }
14903        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14904        Ok(epoch)
14905    }
14906
14907    /// Add a column through the database catalog and shared WAL.
14908    ///
14909    /// This is the catalog-aware counterpart to [`Table::add_column`]. The
14910    /// mounted table schema, catalog image, and recovery record advance as one
14911    /// durable DDL commit.
14912    pub fn add_column(
14913        &self,
14914        table_name: &str,
14915        name: &str,
14916        ty: TypeId,
14917        flags: ColumnFlags,
14918        default_value: Option<crate::schema::DefaultExpr>,
14919    ) -> Result<ColumnDef> {
14920        self.add_column_with_id(table_name, name, ty, flags, default_value, None)
14921    }
14922
14923    /// Add a catalog-aware column with an optional caller-assigned stable id.
14924    pub fn add_column_with_id(
14925        &self,
14926        table_name: &str,
14927        name: &str,
14928        ty: TypeId,
14929        flags: ColumnFlags,
14930        default_value: Option<crate::schema::DefaultExpr>,
14931        requested_id: Option<u16>,
14932    ) -> Result<ColumnDef> {
14933        use crate::wal::DdlOp;
14934        use std::sync::atomic::Ordering;
14935
14936        self.require(&crate::auth::Permission::Ddl)?;
14937        if self.poisoned.load(Ordering::Relaxed) {
14938            return Err(MongrelError::Other(
14939                "database poisoned by fsync error".into(),
14940            ));
14941        }
14942        let _operation = self.admit_operation()?;
14943        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14944        let _ddl = self.ddl_lock.lock();
14945        let _security_write = self.security_write()?;
14946        self.require(&crate::auth::Permission::Ddl)?;
14947        let table_id = {
14948            let catalog = self.catalog.read();
14949            catalog
14950                .live(table_name)
14951                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
14952                .table_id
14953        };
14954        let handle =
14955            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
14956                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
14957            })?;
14958        let durable_epoch = std::cell::Cell::new(None);
14959        let result: Result<ColumnDef> = (|| {
14960            let mut table = handle.lock();
14961            let (column, prepared_schema) =
14962                table.prepare_add_column(name, ty, flags, default_value, requested_id)?;
14963            let command = crate::catalog_cmds::CatalogCommand::AddColumn {
14964                table: table_name.to_string(),
14965                column: column.clone(),
14966            };
14967            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
14968
14969            let commit_lock = Arc::clone(&self.commit_lock);
14970            let _commit = commit_lock.lock();
14971            let epoch = self.epoch.bump_assigned();
14972            let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14973            let txn_id = self.alloc_txn_id()?;
14974            let column_json = DdlOp::encode_column(&column)?;
14975            let mut next_catalog = self.catalog.read().clone();
14976            let catalog_entry_index = next_catalog
14977                .tables
14978                .iter()
14979                .position(|entry| entry.table_id == table_id)
14980                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
14981            self.apply_catalog_command_to(&mut next_catalog, command)?;
14982            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
14983            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14984            let commit_seq = {
14985                let mut wal = self.shared_wal.lock();
14986                let append: Result<u64> = (|| {
14987                    wal.append(
14988                        txn_id,
14989                        table_id,
14990                        crate::wal::Op::Ddl(DdlOp::AlterTable {
14991                            table_id,
14992                            column_json,
14993                        }),
14994                    )?;
14995                    append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14996                    wal.append_commit(txn_id, epoch, &[])
14997                })();
14998                append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14999            };
15000            let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15001            durable_epoch.set(Some(epoch));
15002
15003            table.apply_altered_schema_prepared(prepared_schema);
15004            let schema = table.schema().clone();
15005            let table_checkpoint = table.checkpoint_altered_schema();
15006            drop(table);
15007            next_catalog.tables[catalog_entry_index].schema = schema;
15008            let catalog_result =
15009                catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15010            *self.catalog.write() = next_catalog;
15011            self.publish_committed(&receipt, epoch)?;
15012            epoch_guard.disarm();
15013            if let Err(error) = table_checkpoint.and(catalog_result) {
15014                self.poisoned.store(true, Ordering::Relaxed);
15015                self.lifecycle.poison();
15016                return Err(MongrelError::DurableCommit {
15017                    epoch: epoch.0,
15018                    message: error.to_string(),
15019                });
15020            }
15021            Ok(column)
15022        })();
15023        result.map_err(|error| match (durable_epoch.get(), error) {
15024            (_, error @ MongrelError::DurableCommit { .. }) => error,
15025            (Some(epoch), error) => MongrelError::DurableCommit {
15026                epoch: epoch.0,
15027                message: error.to_string(),
15028            },
15029            (None, error) => error,
15030        })
15031    }
15032
15033    pub fn alter_column(
15034        &self,
15035        table_name: &str,
15036        column_name: &str,
15037        change: AlterColumn,
15038    ) -> Result<ColumnDef> {
15039        self.alter_column_with_epoch(table_name, column_name, change)
15040            .map(|(column, _)| column)
15041    }
15042
15043    pub fn alter_column_with_epoch(
15044        &self,
15045        table_name: &str,
15046        column_name: &str,
15047        change: AlterColumn,
15048    ) -> Result<(ColumnDef, Option<Epoch>)> {
15049        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
15050    }
15051
15052    /// Cooperatively prepare an ALTER and fence each durable commit separately.
15053    /// `after_commit(Some(epoch))` follows an exact durable outcome;
15054    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
15055    /// for every successful `before_commit` callback.
15056    pub fn alter_column_with_epoch_controlled<B, A>(
15057        &self,
15058        table_name: &str,
15059        column_name: &str,
15060        change: AlterColumn,
15061        control: &crate::ExecutionControl,
15062        mut before_commit: B,
15063        mut after_commit: A,
15064    ) -> Result<(ColumnDef, Option<Epoch>)>
15065    where
15066        B: FnMut() -> Result<()>,
15067        A: FnMut(Option<Epoch>) -> Result<()>,
15068    {
15069        self.alter_column_with_epoch_inner(
15070            table_name,
15071            column_name,
15072            change,
15073            Some(control),
15074            Some(&mut before_commit),
15075            Some(&mut after_commit),
15076        )
15077    }
15078
15079    #[allow(clippy::too_many_arguments)]
15080    fn alter_column_with_epoch_inner(
15081        &self,
15082        table_name: &str,
15083        column_name: &str,
15084        change: AlterColumn,
15085        control: Option<&crate::ExecutionControl>,
15086        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
15087        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
15088    ) -> Result<(ColumnDef, Option<Epoch>)> {
15089        use crate::wal::DdlOp;
15090        use std::sync::atomic::Ordering;
15091
15092        self.require(&crate::auth::Permission::Ddl)?;
15093        commit_prepare_checkpoint(control, 0)?;
15094        if self.poisoned.load(Ordering::Relaxed) {
15095            return Err(MongrelError::Other(
15096                "database poisoned by fsync error".into(),
15097            ));
15098        }
15099        // S1A-004: admit the DDL as one core operation.
15100        let _operation = self.admit_operation()?;
15101        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15102        let _g = self.ddl_lock.lock();
15103        let table_id = {
15104            let cat = self.catalog.read();
15105            cat.live(table_name)
15106                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15107                .table_id
15108        };
15109        let handle =
15110            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15111                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15112            })?;
15113
15114        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
15115        // declared default, backfill existing NULL/absent cells as one durable
15116        // transaction before logging the metadata change. A crash between the
15117        // two commits leaves a harmless nullable-but-filled column; retry is
15118        // idempotent because only remaining NULLs are touched.
15119        let backfill = {
15120            let table = handle.lock();
15121            let old = table
15122                .schema()
15123                .column(column_name)
15124                .cloned()
15125                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
15126            let next_flags = change.flags.unwrap_or(old.flags);
15127            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
15128                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
15129                && old.default_value.is_some()
15130            {
15131                let snapshot = self.snapshot_for_epoch(self.epoch.visible());
15132                let mut updates = Vec::new();
15133                let rows = match control {
15134                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
15135                    None => table.visible_rows(snapshot)?,
15136                };
15137                for (row_index, row) in rows.into_iter().enumerate() {
15138                    commit_prepare_checkpoint(control, row_index)?;
15139                    if row
15140                        .columns
15141                        .get(&old.id)
15142                        .is_some_and(|value| !matches!(value, Value::Null))
15143                    {
15144                        continue;
15145                    }
15146                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
15147                    table.apply_defaults(&mut cells)?;
15148                    updates.push((
15149                        table_id,
15150                        crate::txn::Staged::Update {
15151                            row_id: row.row_id,
15152                            new_row: cells,
15153                            changed_columns: vec![old.id],
15154                        },
15155                    ));
15156                }
15157                updates
15158            } else {
15159                Vec::new()
15160            }
15161        };
15162        let durable_epoch = std::cell::Cell::new(None);
15163        let backfill_epoch = if backfill.is_empty() {
15164            None
15165        } else {
15166            let (principal, catalog_bound) = self.transaction_principal_snapshot();
15167            let txn_id = self.alloc_txn_id()?;
15168            let mut entered_fence = false;
15169            let commit_result = match (control, before_commit.as_deref_mut()) {
15170                (Some(control), Some(before_commit)) => self
15171                    .commit_transaction_with_external_states_controlled(
15172                        txn_id,
15173                        self.epoch.visible(),
15174                        backfill,
15175                        Vec::new(),
15176                        Vec::new(),
15177                        principal.clone(),
15178                        catalog_bound,
15179                        None,
15180                        crate::txn::TxnCommitContext::internal(),
15181                        control,
15182                        &mut || {
15183                            before_commit()?;
15184                            entered_fence = true;
15185                            Ok(())
15186                        },
15187                    )
15188                    .map(|(epoch, _)| epoch),
15189                _ => self
15190                    .commit_transaction_with_external_states(
15191                        txn_id,
15192                        self.epoch.visible(),
15193                        backfill,
15194                        Vec::new(),
15195                        Vec::new(),
15196                        principal,
15197                        catalog_bound,
15198                        None,
15199                        crate::txn::TxnCommitContext::internal(),
15200                    )
15201                    .map(|(epoch, _)| epoch),
15202            };
15203            let commit_result = if entered_fence {
15204                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15205            } else {
15206                commit_result
15207            };
15208            match &commit_result {
15209                Ok(epoch) => durable_epoch.set(Some(*epoch)),
15210                Err(MongrelError::DurableCommit { epoch, .. }) => {
15211                    durable_epoch.set(Some(Epoch(*epoch)));
15212                }
15213                Err(_) => {}
15214            }
15215            Some(commit_result?)
15216        };
15217        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
15218            let _security_write = self.security_write()?;
15219            self.require(&crate::auth::Permission::Ddl)?;
15220            if self
15221                .catalog
15222                .read()
15223                .live(table_name)
15224                .is_none_or(|entry| entry.table_id != table_id)
15225            {
15226                return Err(MongrelError::Conflict(format!(
15227                    "table {table_name:?} changed during ALTER"
15228                )));
15229            }
15230            let mut table = handle.lock();
15231            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
15232            let renamed_column = (column.name != column_name).then(|| column.name.clone());
15233            let Some(prepared_schema) = prepared_schema else {
15234                return Ok((column, backfill_epoch));
15235            };
15236            // S1F-001: the schema mutation is a versioned catalog command. It
15237            // validates pure against the current catalog before an epoch is
15238            // consumed; the engine's post-apply schema (schema_id bump
15239            // included) is the resolved delta the wrapper publishes.
15240            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
15241                table: table_name.to_string(),
15242                column: column.clone(),
15243            };
15244            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15245
15246            let commit_lock = Arc::clone(&self.commit_lock);
15247            let _c = commit_lock.lock();
15248            let epoch = self.epoch.bump_assigned();
15249            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15250            let txn_id = self.alloc_txn_id()?;
15251            let column_json = DdlOp::encode_column(&column)?;
15252            let mut next_catalog = self.catalog.read().clone();
15253            let catalog_entry_index = next_catalog
15254                .tables
15255                .iter()
15256                .position(|entry| entry.table_id == table_id)
15257                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15258            if let Some(new_column_name) = &renamed_column {
15259                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
15260                    commit_prepare_checkpoint(control, trigger_index)?;
15261                    if matches!(
15262                        &trigger.trigger.target,
15263                        TriggerTarget::Table(target) if target == table_name
15264                    ) {
15265                        trigger.trigger = trigger.trigger.renamed_update_column(
15266                            column_name,
15267                            new_column_name.clone(),
15268                            epoch.0,
15269                        )?;
15270                    }
15271                }
15272                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
15273                    commit_prepare_checkpoint(control, role_index)?;
15274                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
15275                        commit_prepare_checkpoint(control, permission_index)?;
15276                        rename_permission_column(
15277                            permission,
15278                            table_name,
15279                            column_name,
15280                            new_column_name,
15281                        );
15282                    }
15283                }
15284                advance_security_version(&mut next_catalog)?;
15285            }
15286            // Record the versioned command (validating again against the
15287            // candidate), then install the engine-resolved schema image:
15288            // identical to the command's delta when the mounted table and the
15289            // catalog are in sync, and byte-for-byte what the legacy inline
15290            // mutation published either way.
15291            self.apply_catalog_command_to(&mut next_catalog, command)?;
15292            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15293            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15294            commit_prepare_checkpoint(control, 0)?;
15295            let mut entered_fence = false;
15296            if let Some(before_commit) = before_commit.as_deref_mut() {
15297                before_commit()?;
15298                entered_fence = true;
15299            }
15300            let commit_result: Result<Epoch> = (|| {
15301                let commit_seq = {
15302                    let mut wal = self.shared_wal.lock();
15303                    let append: Result<u64> = (|| {
15304                        wal.append(
15305                            txn_id,
15306                            table_id,
15307                            crate::wal::Op::Ddl(DdlOp::AlterTable {
15308                                table_id,
15309                                column_json,
15310                            }),
15311                        )?;
15312                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15313                        wal.append_commit(txn_id, epoch, &[])
15314                    })();
15315                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15316                };
15317                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15318                durable_epoch.set(Some(epoch));
15319
15320                table.apply_altered_schema_prepared(prepared_schema);
15321                let schema = table.schema().clone();
15322                let table_checkpoint = table.checkpoint_altered_schema();
15323                drop(table);
15324                next_catalog.tables[catalog_entry_index].schema = schema;
15325                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15326                let catalog_result =
15327                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15328                let security_version = next_catalog.security_version;
15329                *self.catalog.write() = next_catalog;
15330                if renamed_column.is_some() {
15331                    self.security_coordinator
15332                        .version
15333                        .store(security_version, Ordering::Release);
15334                }
15335                self.publish_committed(&receipt, epoch)?;
15336                _epoch_guard.disarm();
15337                if let Err(error) = table_checkpoint.and(catalog_result) {
15338                    self.poisoned.store(true, Ordering::Relaxed);
15339                    self.lifecycle.poison();
15340                    return Err(MongrelError::DurableCommit {
15341                        epoch: epoch.0,
15342                        message: error.to_string(),
15343                    });
15344                }
15345                Ok(epoch)
15346            })();
15347            let commit_result = if entered_fence {
15348                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15349            } else {
15350                commit_result
15351            };
15352            let epoch = commit_result?;
15353            Ok((column, Some(epoch)))
15354        })();
15355        result.map_err(|error| match (durable_epoch.get(), error) {
15356            (_, error @ MongrelError::DurableCommit { .. }) => error,
15357            (Some(epoch), error) => MongrelError::DurableCommit {
15358                epoch: epoch.0,
15359                message: error.to_string(),
15360            },
15361            (None, error) => error,
15362        })
15363    }
15364
15365    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
15366    /// replication. Duration is in nanoseconds.
15367    pub fn set_table_ttl(
15368        &self,
15369        table_name: &str,
15370        column_name: &str,
15371        duration_nanos: u64,
15372    ) -> Result<crate::manifest::TtlPolicy> {
15373        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
15374        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
15375    }
15376
15377    /// Set TTL metadata on a hidden build before it is published.
15378    #[doc(hidden)]
15379    pub fn set_building_table_ttl(
15380        &self,
15381        table_name: &str,
15382        column_name: &str,
15383        duration_nanos: u64,
15384    ) -> Result<crate::manifest::TtlPolicy> {
15385        let policy = self.replace_table_ttl_with_state(
15386            table_name,
15387            Some((column_name, duration_nanos)),
15388            true,
15389        )?;
15390        policy
15391            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
15392    }
15393
15394    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
15395        self.replace_table_ttl(table_name, None)?;
15396        Ok(())
15397    }
15398
15399    fn replace_table_ttl(
15400        &self,
15401        table_name: &str,
15402        requested: Option<(&str, u64)>,
15403    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15404        self.replace_table_ttl_with_state(table_name, requested, false)
15405    }
15406
15407    fn replace_table_ttl_with_state(
15408        &self,
15409        table_name: &str,
15410        requested: Option<(&str, u64)>,
15411        building: bool,
15412    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15413        use crate::wal::DdlOp;
15414        use std::sync::atomic::Ordering;
15415
15416        self.require(&crate::auth::Permission::Ddl)?;
15417        if self.poisoned.load(Ordering::Relaxed) {
15418            return Err(MongrelError::Other(
15419                "database poisoned by fsync error".into(),
15420            ));
15421        }
15422
15423        let _g = self.ddl_lock.lock();
15424        let _security_write = self.security_write()?;
15425        self.require(&crate::auth::Permission::Ddl)?;
15426        let table_id = {
15427            let cat = self.catalog.read();
15428            if building {
15429                cat.building(table_name)
15430            } else {
15431                cat.live(table_name)
15432            }
15433            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15434            .table_id
15435        };
15436        let handle =
15437            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15438                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15439            })?;
15440        let mut table = handle.lock();
15441        let policy = match requested {
15442            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
15443            None => None,
15444        };
15445        if table.ttl() == policy {
15446            return Ok(policy);
15447        }
15448
15449        let commit_lock = Arc::clone(&self.commit_lock);
15450        let _c = commit_lock.lock();
15451        let epoch = self.epoch.bump_assigned();
15452        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15453        let txn_id = self.alloc_txn_id()?;
15454        let policy_json = DdlOp::encode_ttl(policy)?;
15455        let mut next_catalog = self.catalog.read().clone();
15456        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15457        let commit_seq = {
15458            let mut wal = self.shared_wal.lock();
15459            let append: Result<u64> = (|| {
15460                wal.append(
15461                    txn_id,
15462                    table_id,
15463                    crate::wal::Op::Ddl(DdlOp::SetTtl {
15464                        table_id,
15465                        policy_json,
15466                    }),
15467                )?;
15468                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15469                wal.append_commit(txn_id, epoch, &[])
15470            })();
15471            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15472        };
15473        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15474
15475        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
15476        drop(table);
15477        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
15478            publish_error.get_or_insert(error);
15479        }
15480        self.finish_durable_publish(
15481            epoch,
15482            &mut _epoch_guard,
15483            &receipt,
15484            publish_error.map_or(Ok(()), Err),
15485        )?;
15486        Ok(policy)
15487    }
15488
15489    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
15490    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
15491    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
15492    ///
15493    /// Returns the number of items reclaimed.
15494    pub fn gc(&self) -> Result<usize> {
15495        let control = crate::ExecutionControl::new(None);
15496        self.gc_controlled(&control, || true)
15497    }
15498
15499    /// Discover reclaimable state cooperatively, then cross one publication
15500    /// boundary immediately before the first irreversible deletion.
15501    #[doc(hidden)]
15502    pub fn gc_controlled<F>(
15503        &self,
15504        control: &crate::ExecutionControl,
15505        before_publish: F,
15506    ) -> Result<usize>
15507    where
15508        F: FnOnce() -> bool,
15509    {
15510        self.gc_controlled_with_receipt(control, before_publish)
15511            .map(|(reclaimed, _)| reclaimed)
15512    }
15513
15514    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
15515    /// return that snapshot if an irreversible deletion was attempted.
15516    #[doc(hidden)]
15517    pub fn gc_controlled_with_receipt<F>(
15518        &self,
15519        control: &crate::ExecutionControl,
15520        before_publish: F,
15521    ) -> Result<(usize, Option<MaintenanceReceipt>)>
15522    where
15523        F: FnOnce() -> bool,
15524    {
15525        enum Candidate {
15526            Directory(PathBuf),
15527            File(PathBuf),
15528        }
15529
15530        self.require(&crate::auth::Permission::Ddl)?;
15531        // S1A-004: admit the maintenance pass as one core operation.
15532        let _operation = self.admit_operation()?;
15533        let _ddl = self.ddl_lock.lock();
15534        self.require(&crate::auth::Permission::Ddl)?;
15535        control.checkpoint()?;
15536        let maintenance_epoch = self.epoch.visible();
15537        let min_active = self.snapshots.min_active(maintenance_epoch).0;
15538        let mut candidates = Vec::new();
15539
15540        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
15541        let cat = self.catalog.read();
15542        for (entry_index, entry) in cat.tables.iter().enumerate() {
15543            if entry_index % 256 == 0 {
15544                control.checkpoint()?;
15545            }
15546            if let TableState::Dropped { at_epoch } = entry.state {
15547                if at_epoch <= min_active {
15548                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
15549                    if tdir.exists() {
15550                        candidates.push(Candidate::Directory(tdir));
15551                    }
15552                }
15553            }
15554        }
15555        drop(cat);
15556
15557        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
15558        // in-flight spill's dir (deleting it would lose the pending run and fail
15559        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
15560        // skip any id still registered in `active_spills`.
15561        let cat = self.catalog.read();
15562        for (entry_index, entry) in cat.tables.iter().enumerate() {
15563            if entry_index % 256 == 0 {
15564                control.checkpoint()?;
15565            }
15566            if !matches!(entry.state, TableState::Live) {
15567                continue;
15568            }
15569            let txn_dir = self
15570                .root
15571                .join(TABLES_DIR)
15572                .join(entry.table_id.to_string())
15573                .join("_txn");
15574            if !txn_dir.exists() {
15575                continue;
15576            }
15577            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
15578                if sub_index % 256 == 0 {
15579                    control.checkpoint()?;
15580                }
15581                let sub = sub?;
15582                let name = sub.file_name();
15583                let Some(name) = name.to_str() else { continue };
15584                // A non-numeric entry can't belong to a live txn — sweep it.
15585                let is_active = name
15586                    .parse::<u64>()
15587                    .map(|id| self.active_spills.is_active(id))
15588                    .unwrap_or(false);
15589                if is_active {
15590                    continue;
15591                }
15592                candidates.push(Candidate::Directory(sub.path()));
15593            }
15594        }
15595        drop(cat);
15596
15597        let external_names = {
15598            let cat = self.catalog.read();
15599            cat.external_tables
15600                .iter()
15601                .map(|entry| entry.name.clone())
15602                .collect::<std::collections::HashSet<_>>()
15603        };
15604        let vtab_dir = self.root.join(VTAB_DIR);
15605        if vtab_dir.exists() {
15606            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
15607                if entry_index % 256 == 0 {
15608                    control.checkpoint()?;
15609                }
15610                let entry = entry?;
15611                let name = entry.file_name();
15612                let Some(name) = name.to_str() else { continue };
15613                if external_names.contains(name) {
15614                    continue;
15615                }
15616                let path = entry.path();
15617                if path.is_dir() {
15618                    candidates.push(Candidate::Directory(path));
15619                } else {
15620                    candidates.push(Candidate::File(path));
15621                }
15622            }
15623        }
15624
15625        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
15626        // can still need (spec §6.4). Each table deletes its own retired files
15627        // gated on `min_active` and persists its manifest.
15628        let tables = self
15629            .tables
15630            .read()
15631            .iter()
15632            .map(|(table_id, handle)| (*table_id, handle.clone()))
15633            .collect::<Vec<_>>();
15634        let mut retiring = Vec::new();
15635        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
15636            if table_index % 256 == 0 {
15637                control.checkpoint()?;
15638            }
15639            let backup_pinned: HashSet<u128> = self
15640                .backup_pins
15641                .lock()
15642                .keys()
15643                .filter_map(|(pinned_table, run_id)| {
15644                    (*pinned_table == *table_id).then_some(*run_id)
15645                })
15646                .collect();
15647            if handle
15648                .lock()
15649                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
15650            {
15651                retiring.push((handle.clone(), backup_pinned));
15652            }
15653        }
15654
15655        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
15656        // segment on every reopen without truncating the prior ones, so rotated
15657        // segments accumulate. Once every live table's committed data is durable
15658        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
15659        // (non-active) segments are redundant for recovery and safe to delete —
15660        // an in-flight txn only ever appends to the active segment, which is
15661        // never deleted.
15662        let all_durable = self.active_spills.is_idle()
15663            && tables.iter().all(|(_, handle)| {
15664                let g = handle.lock();
15665                g.memtable_len() == 0 && g.mutable_run_len() == 0
15666            });
15667        let retain = self
15668            .replication_wal_retention_segments
15669            .load(std::sync::atomic::Ordering::Relaxed);
15670        let reap_wal = all_durable
15671            && self
15672                .shared_wal
15673                .lock()
15674                .has_gc_segments_retain_recent(retain)?;
15675
15676        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
15677            return Ok((0, None));
15678        }
15679        control.checkpoint()?;
15680        if !before_publish() {
15681            return Err(MongrelError::Cancelled);
15682        }
15683
15684        let mut reclaimed = 0;
15685        for candidate in candidates {
15686            match candidate {
15687                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
15688                Candidate::File(path) => std::fs::remove_file(path)?,
15689            }
15690            reclaimed += 1;
15691        }
15692        for (handle, backup_pinned) in retiring {
15693            reclaimed += handle
15694                .lock()
15695                .reap_retiring(Epoch(min_active), &backup_pinned)?;
15696        }
15697        if reap_wal {
15698            reclaimed += self
15699                .shared_wal
15700                .lock()
15701                .gc_segments_retain_recent(u64::MAX, retain)?;
15702        }
15703
15704        Ok((
15705            reclaimed,
15706            Some(MaintenanceReceipt {
15707                epoch: maintenance_epoch,
15708            }),
15709        ))
15710    }
15711
15712    /// Produce a deterministic-stable byte image of the database directory.
15713    ///
15714    /// After `checkpoint()`:
15715    ///   - All pending writes are flushed to sorted runs (no memtable data).
15716    ///   - Each table is compacted to a single sorted run (no run fragmentation).
15717    ///   - All non-active WAL segments are deleted (data is durable in runs).
15718    ///   - The active WAL segment is rotated to a fresh empty segment.
15719    ///   - Dropped-table directories are removed.
15720    ///   - All manifests + catalog are persisted.
15721    ///
15722    /// The resulting directory is byte-stable: `git add` captures a snapshot
15723    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
15724    /// no unbounded segment growth, no mutable-run spill files.
15725    ///
15726    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
15727    /// It does NOT clear the exclusive lock — the caller still owns the
15728    /// database handle.
15729    pub fn checkpoint(&self) -> Result<()> {
15730        self.checkpoint_controlled(|| Ok(()))
15731    }
15732
15733    /// Strict checkpoint with a deterministic test hook after every table is
15734    /// flushed/compacted but before WAL replacement.
15735    #[doc(hidden)]
15736    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
15737    where
15738        F: FnOnce() -> Result<()>,
15739    {
15740        self.require(&crate::auth::Permission::Ddl)?;
15741        // S1A-004: admit the checkpoint as one core operation.
15742        let _operation = self.admit_operation()?;
15743        // Block cross-table commits and DDL for the full operation. Locking all
15744        // mounted handles also excludes direct `Table` commits, which do not
15745        // enter the database replication barrier.
15746        let _replication = self.replication_barrier.write();
15747        let _ddl = self.ddl_lock.lock();
15748        let _security = self.security_coordinator.gate.read();
15749        self.require(&crate::auth::Permission::Ddl)?;
15750
15751        let mut handles = self
15752            .tables
15753            .read()
15754            .iter()
15755            .map(|(table_id, handle)| (*table_id, handle.clone()))
15756            .collect::<Vec<_>>();
15757        handles.sort_by_key(|(table_id, _)| *table_id);
15758        let mut tables = handles
15759            .iter()
15760            .map(|(table_id, handle)| (*table_id, handle.lock()))
15761            .collect::<Vec<_>>();
15762
15763        // Strict flush. Any error leaves the old WAL recovery source intact.
15764        for (_, table) in &mut tables {
15765            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
15766            {
15767                table.force_flush()?;
15768            }
15769        }
15770
15771        // Strict compaction. Checkpoint never reports a stable image after a
15772        // skipped failure.
15773        for (_, table) in &mut tables {
15774            if table.run_count() >= 2 || table.should_compact() {
15775                table.compact()?;
15776            }
15777        }
15778
15779        before_wal_reset()?;
15780
15781        // Reap table-local retired runs while every table remains quiesced.
15782        let maintenance_epoch = self.epoch.visible();
15783        let min_active = self.snapshots.min_active(maintenance_epoch);
15784        for (table_id, table) in &mut tables {
15785            let backup_pinned: HashSet<u128> = self
15786                .backup_pins
15787                .lock()
15788                .keys()
15789                .filter_map(|(pinned_table, run_id)| {
15790                    (*pinned_table == *table_id).then_some(*run_id)
15791                })
15792                .collect();
15793            table.reap_retiring(min_active, &backup_pinned)?;
15794        }
15795
15796        // Publish a fresh synced active WAL, then durably reap every older
15797        // segment. This point is reached only after every strict flush succeeds.
15798        self.shared_wal.lock().reset_after_checkpoint()?;
15799
15800        // Remove catalog-unreachable directories and stale transaction state.
15801        let catalog_snapshot = self.catalog.read().clone();
15802        for entry in &catalog_snapshot.tables {
15803            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
15804                crate::durable_file::remove_directory_all(
15805                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
15806                )?;
15807            }
15808            if !matches!(entry.state, TableState::Live) {
15809                continue;
15810            }
15811            let transaction_dir = self
15812                .root
15813                .join(TABLES_DIR)
15814                .join(entry.table_id.to_string())
15815                .join("_txn");
15816            if transaction_dir.is_dir() {
15817                for child in std::fs::read_dir(&transaction_dir)? {
15818                    let child = child?;
15819                    let active = child
15820                        .file_name()
15821                        .to_str()
15822                        .and_then(|name| name.parse::<u64>().ok())
15823                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
15824                    if !active {
15825                        crate::durable_file::remove_directory_all(&child.path())?;
15826                    }
15827                }
15828            }
15829        }
15830        let external_names = catalog_snapshot
15831            .external_tables
15832            .iter()
15833            .map(|entry| entry.name.as_str())
15834            .collect::<HashSet<_>>();
15835        let external_root = self.root.join(VTAB_DIR);
15836        if external_root.is_dir() {
15837            for entry in std::fs::read_dir(&external_root)? {
15838                let entry = entry?;
15839                let name = entry.file_name();
15840                if name
15841                    .to_str()
15842                    .is_some_and(|name| external_names.contains(name))
15843                {
15844                    continue;
15845                }
15846                if entry.file_type()?.is_dir() {
15847                    crate::durable_file::remove_directory_all(&entry.path())?;
15848                } else {
15849                    std::fs::remove_file(entry.path())?;
15850                    crate::durable_file::sync_directory(&external_root)?;
15851                }
15852            }
15853        }
15854
15855        // Final authoritative metadata checkpoint while all writers remain
15856        // excluded.
15857        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
15858        let visible = self.epoch.visible();
15859        for (_, table) in &tables {
15860            table.persist_manifest(visible)?;
15861        }
15862
15863        Ok(())
15864    }
15865    fn alloc_txn_id(&self) -> Result<u64> {
15866        self.ensure_owner_process()?;
15867        crate::txn::allocate_txn_id(&self.next_txn_id)
15868    }
15869
15870    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
15871    /// (or other multi-statement lock holds). Released via
15872    /// [`Self::release_txn_locks`].
15873    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
15874        self.alloc_txn_id()
15875    }
15876
15877    /// Set the per-table spill threshold (bytes). When a transaction's staged
15878    /// bytes for a single table exceed this, the rows are written as a
15879    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
15880    pub fn set_spill_threshold(&self, bytes: u64) {
15881        self.spill_threshold
15882            .store(bytes, std::sync::atomic::Ordering::Relaxed);
15883    }
15884
15885    /// Test-only: install a hook invoked after a transaction writes its spill
15886    /// runs but before the sequencer, so a test can race `gc()` against an
15887    /// in-flight spill. Not part of the stable API.
15888    #[doc(hidden)]
15889    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15890        *self.spill_hook.lock() = Some(Box::new(f));
15891    }
15892
15893    /// Test-only: install a hook invoked while a spilled commit holds the
15894    /// security read gate and before it appends to the WAL.
15895    #[doc(hidden)]
15896    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15897        *self.security_commit_hook.lock() = Some(Box::new(f));
15898    }
15899
15900    /// Test-only: install a hook after transaction preparation and before the
15901    /// commit sequencer validates catalog generations.
15902    #[doc(hidden)]
15903    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15904        *self.catalog_commit_hook.lock() = Some(Box::new(f));
15905    }
15906
15907    /// Test-only: pause an online backup after its consistent boundary is
15908    /// captured but before the pinned immutable runs are copied.
15909    #[doc(hidden)]
15910    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15911        *self.backup_hook.lock() = Some(Box::new(f));
15912    }
15913
15914    /// Test-only: invoked after each successful FK parent-protection lock
15915    /// acquisition during constraint validation, so tests can rendezvous two
15916    /// committing transactions into a deterministic wait-for cycle.
15917    #[doc(hidden)]
15918    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15919        *self.fk_lock_hook.lock() = Some(Arc::new(f));
15920    }
15921
15922    /// Test-only: pause WAL extraction before its final principal recheck.
15923    #[doc(hidden)]
15924    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15925        *self.replication_hook.lock() = Some(Box::new(f));
15926    }
15927
15928    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
15929    /// this stays well below the number of committed transactions when commits
15930    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
15931    #[doc(hidden)]
15932    pub fn __wal_group_sync_count(&self) -> u64 {
15933        self.shared_wal.lock().group_sync_count()
15934    }
15935
15936    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
15937    /// contract that an fsync error would trigger in production.
15938    #[doc(hidden)]
15939    pub fn __poison(&self) {
15940        self.poisoned
15941            .store(true, std::sync::atomic::Ordering::Relaxed);
15942    }
15943
15944    /// Verify multi-table integrity (spec §16). For every live table this:
15945    /// authenticates the manifest; opens each `RunRef`'s file through
15946    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
15947    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
15948    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
15949    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
15950    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
15951    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
15952    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
15953    ///
15954    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
15955    /// full body, so this is an integrity tool, not a hot path.
15956    pub fn check(&self) -> Vec<CheckIssue> {
15957        match self.check_inner(None) {
15958            Ok(issues) => issues,
15959            Err(error) => vec![CheckIssue {
15960                table_id: WAL_TABLE_ID,
15961                table_name: "shared WAL".into(),
15962                severity: "error".into(),
15963                description: error.to_string(),
15964            }],
15965        }
15966    }
15967
15968    /// Integrity check with cooperative cancellation between tables and runs.
15969    #[doc(hidden)]
15970    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
15971        self.check_inner(Some(control))
15972    }
15973
15974    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
15975        let mut issues = Vec::new();
15976        let io_root = self.durable_root.io_path()?;
15977        let cat = self.catalog.read();
15978        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
15979        for (table_index, entry) in cat.tables.iter().enumerate() {
15980            if table_index % 256 == 0 {
15981                if let Some(control) = control {
15982                    control.checkpoint()?;
15983                }
15984            }
15985            if !matches!(entry.state, TableState::Live) {
15986                continue;
15987            }
15988            let tdir = io_root.join(TABLES_DIR).join(entry.table_id.to_string());
15989            let mut err = |sev: &str, desc: String| {
15990                issues.push(CheckIssue {
15991                    table_id: entry.table_id,
15992                    table_name: entry.name.clone(),
15993                    severity: sev.into(),
15994                    description: desc,
15995                });
15996            };
15997            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
15998                Ok(m) => m,
15999                Err(e) => {
16000                    err("error", format!("manifest read failed: {e}"));
16001                    continue;
16002                }
16003            };
16004            if m.flushed_epoch > m.current_epoch {
16005                err(
16006                    "error",
16007                    format!(
16008                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
16009                        m.flushed_epoch, m.current_epoch
16010                    ),
16011                );
16012            }
16013
16014            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
16015            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
16016            for (run_index, rr) in m.runs.iter().enumerate() {
16017                if run_index % 256 == 0 {
16018                    if let Some(control) = control {
16019                        control.checkpoint()?;
16020                    }
16021                }
16022                referenced.insert(rr.run_id);
16023                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
16024                if !run_path.exists() {
16025                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
16026                    continue;
16027                }
16028                match crate::sorted_run::RunReader::open(
16029                    &run_path,
16030                    entry.schema.clone(),
16031                    self.kek.clone(),
16032                ) {
16033                    Ok(reader) => {
16034                        if reader.row_count() as u64 != rr.row_count {
16035                            err(
16036                                "error",
16037                                format!(
16038                                    "run r-{} row count mismatch: manifest {} vs run {}",
16039                                    rr.run_id,
16040                                    rr.row_count,
16041                                    reader.row_count()
16042                                ),
16043                            );
16044                        }
16045                    }
16046                    Err(e) => {
16047                        err(
16048                            "error",
16049                            format!("run r-{} integrity check failed: {e}", rr.run_id),
16050                        );
16051                    }
16052                }
16053            }
16054
16055            // Compaction-superseded runs awaiting retention-gated deletion are
16056            // tracked in `retiring`; their files are expected on disk, so they
16057            // are not orphans.
16058            for r in &m.retiring {
16059                referenced.insert(r.run_id);
16060            }
16061
16062            // Orphan `.sr` files present on disk but absent from the manifest.
16063            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
16064                for (entry_index, ent) in rd.flatten().enumerate() {
16065                    if entry_index % 256 == 0 {
16066                        if let Some(control) = control {
16067                            control.checkpoint()?;
16068                        }
16069                    }
16070                    let p = ent.path();
16071                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
16072                        continue;
16073                    }
16074                    let run_id = p
16075                        .file_stem()
16076                        .and_then(|s| s.to_str())
16077                        .and_then(|s| s.strip_prefix("r-"))
16078                        .and_then(|s| s.parse::<u128>().ok());
16079                    if let Some(id) = run_id {
16080                        if !referenced.contains(&id) {
16081                            err(
16082                                "warning",
16083                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
16084                            );
16085                        }
16086                    }
16087                }
16088            }
16089        }
16090
16091        let external_names = cat
16092            .external_tables
16093            .iter()
16094            .map(|entry| entry.name.clone())
16095            .collect::<std::collections::HashSet<_>>();
16096        let vtab_dir = io_root.join(VTAB_DIR);
16097        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
16098            for (entry_index, entry) in entries.flatten().enumerate() {
16099                if entry_index % 256 == 0 {
16100                    if let Some(control) = control {
16101                        control.checkpoint()?;
16102                    }
16103                }
16104                let name = entry.file_name();
16105                let Some(name) = name.to_str() else { continue };
16106                if !external_names.contains(name) {
16107                    issues.push(CheckIssue {
16108                        table_id: EXTERNAL_TABLE_ID,
16109                        table_name: name.to_string(),
16110                        severity: "warning".into(),
16111                        description: format!(
16112                            "orphan external table state entry {:?} not referenced by the catalog",
16113                            entry.path()
16114                        ),
16115                    });
16116                }
16117            }
16118        }
16119
16120        // WAL retention / integrity invariant (spec §16): every on-disk WAL
16121        // segment must open (header magic + version, and the frame cipher must
16122        // be derivable for an encrypted WAL). A segment that won't open is
16123        // corrupt or truncated and would break crash recovery. `table_id` is
16124        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
16125        // never confuses a WAL issue with a real table.
16126        if let Some(control) = control {
16127            control.checkpoint()?;
16128        }
16129        for (seg, msg) in self.shared_wal.lock().verify_segments() {
16130            issues.push(CheckIssue {
16131                table_id: WAL_TABLE_ID,
16132                table_name: "<wal>".into(),
16133                severity: "error".into(),
16134                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
16135            });
16136        }
16137        Ok(issues)
16138    }
16139
16140    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
16141    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
16142    /// unmounts them from the live table map so the DB still opens.
16143    pub fn doctor(&self) -> Result<Vec<u64>> {
16144        let control = crate::ExecutionControl::new(None);
16145        self.doctor_controlled(&control, || true)
16146    }
16147
16148    /// Check cancellably, then fence immediately before the first quarantine
16149    /// mutation. Returning `false` from `before_publish` leaves the database
16150    /// untouched.
16151    #[doc(hidden)]
16152    pub fn doctor_controlled<F>(
16153        &self,
16154        control: &crate::ExecutionControl,
16155        before_publish: F,
16156    ) -> Result<Vec<u64>>
16157    where
16158        F: FnOnce() -> bool,
16159    {
16160        self.doctor_controlled_with_receipt(control, before_publish)
16161            .map(|(quarantined, _)| quarantined)
16162    }
16163
16164    /// Check cancellably and return the exact catalog epoch used for a
16165    /// quarantine publication. No receipt is returned when nothing changes.
16166    #[doc(hidden)]
16167    pub fn doctor_controlled_with_receipt<F>(
16168        &self,
16169        control: &crate::ExecutionControl,
16170        before_publish: F,
16171    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
16172    where
16173        F: FnOnce() -> bool,
16174    {
16175        // Hold the DDL lock for the whole operation to prevent concurrent
16176        // create_table/drop_table from racing the catalog/dir mutation.
16177        let _ddl = self.ddl_lock.lock();
16178        let _security_write = self.security_write()?;
16179        let issues = self.check_inner(Some(control))?;
16180        // A corrupt WAL segment is reported as an error but is NOT a table
16181        // problem — quarantining an innocent table cannot fix it (and the first
16182        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
16183        // them disjoint). The admin must address WAL corruption manually.
16184        let bad_tables: std::collections::HashSet<u64> = issues
16185            .iter()
16186            .filter(|i| {
16187                i.severity == "error"
16188                    && i.table_id != WAL_TABLE_ID
16189                    && i.table_id != EXTERNAL_TABLE_ID
16190            })
16191            .map(|i| i.table_id)
16192            .collect();
16193        if bad_tables.is_empty() {
16194            return Ok((Vec::new(), None));
16195        }
16196        let _commit = self.commit_lock.lock();
16197        control.checkpoint()?;
16198        if !before_publish() {
16199            return Err(MongrelError::Cancelled);
16200        }
16201        let maintenance_epoch = self.epoch.bump_assigned();
16202        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
16203
16204        let qdir = self.root.join("_quarantine");
16205        crate::durable_file::create_directory(&qdir)?;
16206        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
16207        bad_tables.sort_unstable();
16208
16209        // Quiesce every mounted target before catalog publication. Existing
16210        // handle clones are marked unavailable in the publication callback so
16211        // they cannot append to the shared WAL after their catalog entry drops.
16212        let mut handles = self
16213            .tables
16214            .read()
16215            .iter()
16216            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
16217            .map(|(table_id, handle)| (*table_id, handle.clone()))
16218            .collect::<Vec<_>>();
16219        handles.sort_by_key(|(table_id, _)| *table_id);
16220        let mut table_guards = handles
16221            .iter()
16222            .map(|(table_id, handle)| (*table_id, handle.lock()))
16223            .collect::<Vec<_>>();
16224
16225        let mut next_catalog = self.catalog.read().clone();
16226        for table_id in &bad_tables {
16227            if let Some(entry) = next_catalog
16228                .tables
16229                .iter_mut()
16230                .find(|entry| entry.table_id == *table_id)
16231            {
16232                entry.state = TableState::Dropped {
16233                    at_epoch: maintenance_epoch.0,
16234                };
16235            }
16236        }
16237        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
16238
16239        let txn_id = self.alloc_txn_id()?;
16240        let commit_seq = {
16241            let mut wal = self.shared_wal.lock();
16242            let append: Result<u64> = (|| {
16243                for table_id in &bad_tables {
16244                    wal.append(
16245                        txn_id,
16246                        *table_id,
16247                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
16248                            table_id: *table_id,
16249                        }),
16250                    )?;
16251                }
16252                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
16253                wal.append_commit(txn_id, maintenance_epoch, &[])
16254            })();
16255            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
16256        };
16257        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
16258        for (_, table) in &mut table_guards {
16259            table.mark_unavailable_after_quarantine();
16260        }
16261        {
16262            let mut live_tables = self.tables.write();
16263            for table_id in &bad_tables {
16264                live_tables.remove(table_id);
16265            }
16266        }
16267        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
16268        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
16269
16270        // Release DOCTOR's own table guards and handle clones before moving
16271        // the directory. Windows refuses to rename files held open by the
16272        // final mounted Table instance.
16273        drop(table_guards);
16274        drop(handles);
16275
16276        // The catalog drop is durable. Directory placement is secondary but
16277        // still uses a write-through rename. A failure reports the known
16278        // catalog outcome and leaves a harmless orphan under `tables/`.
16279        for table_id in &bad_tables {
16280            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
16281            if source.exists() {
16282                let destination = qdir.join(table_id.to_string());
16283                if let Err(error) = crate::durable_file::rename(&source, &destination) {
16284                    return Err(MongrelError::DurableCommit {
16285                        epoch: maintenance_epoch.0,
16286                        message: format!(
16287                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
16288                        ),
16289                    });
16290                }
16291            }
16292        }
16293        Ok((
16294            bad_tables,
16295            Some(MaintenanceReceipt {
16296                epoch: maintenance_epoch,
16297            }),
16298        ))
16299    }
16300
16301    /// The DB-wide KEK (if encrypted).
16302    #[allow(dead_code)]
16303    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
16304        self.kek.as_ref()
16305    }
16306
16307    /// Shared epoch authority (used by the transaction layer in P2).
16308    #[allow(dead_code)]
16309    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
16310        &self.epoch
16311    }
16312
16313    /// Shared snapshot registry (used by GC in P3.6).
16314    #[allow(dead_code)]
16315    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
16316        &self.snapshots
16317    }
16318}
16319
16320fn external_state_dir(root: &Path, name: &str) -> PathBuf {
16321    root.join(VTAB_DIR).join(name)
16322}
16323
16324fn append_catalog_snapshot(
16325    wal: &mut crate::wal::SharedWal,
16326    txn_id: u64,
16327    catalog: &Catalog,
16328) -> Result<()> {
16329    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
16330    wal.append(
16331        txn_id,
16332        WAL_TABLE_ID,
16333        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
16334    )?;
16335    Ok(())
16336}
16337
16338fn filter_ignored_staging(
16339    staging: Vec<(u64, crate::txn::Staged)>,
16340    ignored_indices: &std::collections::BTreeSet<usize>,
16341) -> Vec<(u64, crate::txn::Staged)> {
16342    if ignored_indices.is_empty() {
16343        return staging;
16344    }
16345    staging
16346        .into_iter()
16347        .enumerate()
16348        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
16349        .collect()
16350}
16351
16352fn external_state_file(root: &Path, name: &str) -> PathBuf {
16353    external_state_dir(root, name).join("state.json")
16354}
16355
16356fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
16357    let path = external_state_file(root, name);
16358    match std::fs::read(path) {
16359        Ok(bytes) => Ok(bytes),
16360        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
16361        Err(e) => Err(e.into()),
16362    }
16363}
16364
16365fn current_external_state_bytes(
16366    root: &Path,
16367    external_states: &[(String, Vec<u8>)],
16368    name: &str,
16369) -> Result<Vec<u8>> {
16370    for (table, state) in external_states.iter().rev() {
16371        if table == name {
16372            return Ok(state.clone());
16373        }
16374    }
16375    read_external_state_file(root, name)
16376}
16377
16378fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
16379    let mut out = external_states;
16380    dedup_external_states_in_place(&mut out);
16381    out
16382}
16383
16384fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
16385    let mut seen = std::collections::HashSet::new();
16386    let mut out = Vec::with_capacity(external_states.len());
16387    for (name, state) in std::mem::take(external_states).into_iter().rev() {
16388        if seen.insert(name.clone()) {
16389            out.push((name, state));
16390        }
16391    }
16392    out.reverse();
16393    *external_states = out;
16394}
16395
16396fn prepare_external_state_file(
16397    root: &Path,
16398    name: &str,
16399    state: &[u8],
16400    txn_id: u64,
16401) -> Result<PathBuf> {
16402    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
16403    let dir = external_state_dir(root, name);
16404    crate::durable_file::create_directory(&dir)?;
16405    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
16406    {
16407        let mut file = std::fs::OpenOptions::new()
16408            .create_new(true)
16409            .write(true)
16410            .open(&pending)?;
16411        file.write_all(state)?;
16412        file.sync_all()?;
16413    }
16414    Ok(pending)
16415}
16416
16417fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
16418    let path = external_state_file(root, name);
16419    crate::durable_file::replace(pending, &path)?;
16420    Ok(())
16421}
16422
16423fn write_external_state_file(
16424    durable: &crate::durable_file::DurableRoot,
16425    name: &str,
16426    state: &[u8],
16427) -> Result<()> {
16428    let directory = Path::new(VTAB_DIR).join(name);
16429    durable.create_directory_all(&directory)?;
16430    durable.write_atomic(directory.join("state.json"), state)?;
16431    Ok(())
16432}
16433
16434fn validate_recovered_data_table(
16435    catalog: &Catalog,
16436    tables: &HashMap<u64, TableHandle>,
16437    table_id: u64,
16438    commit_epoch: u64,
16439    offset: u64,
16440) -> Result<bool> {
16441    let entry = catalog
16442        .tables
16443        .iter()
16444        .find(|entry| entry.table_id == table_id)
16445        .ok_or_else(|| MongrelError::CorruptWal {
16446            offset,
16447            reason: format!("committed record references unknown table {table_id}"),
16448        })?;
16449    if commit_epoch < entry.created_epoch {
16450        return Err(MongrelError::CorruptWal {
16451            offset,
16452            reason: format!(
16453                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16454                entry.created_epoch
16455            ),
16456        });
16457    }
16458    match entry.state {
16459        TableState::Dropped { at_epoch } => {
16460            // Abandoned hidden builds are marked dropped at the last durable
16461            // boundary during open, so their final build commit may equal the
16462            // cleanup epoch. Ordinary table drops consume a new epoch and must
16463            // remain strictly later than every data commit.
16464            let abandoned_build_boundary =
16465                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16466            if commit_epoch >= at_epoch && !abandoned_build_boundary {
16467                Err(MongrelError::CorruptWal {
16468                    offset,
16469                    reason: format!(
16470                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16471                    ),
16472                })
16473            } else {
16474                Ok(false)
16475            }
16476        }
16477        TableState::Live | TableState::Building { .. } => {
16478            if tables.contains_key(&table_id) {
16479                Ok(true)
16480            } else {
16481                Err(MongrelError::CorruptWal {
16482                    offset,
16483                    reason: format!("live table {table_id} has no mounted recovery handle"),
16484                })
16485            }
16486        }
16487    }
16488}
16489
16490type RecoveryTableStage = (
16491    Vec<crate::memtable::Row>,
16492    Vec<(crate::rowid::RowId, Epoch)>,
16493    Option<Epoch>,
16494    Epoch,
16495);
16496
16497#[derive(Clone)]
16498struct RecoveryValidationTable {
16499    schema: Schema,
16500    flushed_epoch: u64,
16501}
16502
16503fn validate_shared_wal_recovery_plan(
16504    durable_root: &crate::durable_file::DurableRoot,
16505    catalog: &Catalog,
16506    recovered_table_ids: &HashSet<u64>,
16507    reconciled_table_ids: &HashSet<u64>,
16508    meta_dek: Option<&[u8; META_DEK_LEN]>,
16509    kek: Option<Arc<crate::encryption::Kek>>,
16510    records: &[crate::wal::Record],
16511) -> Result<()> {
16512    use crate::wal::{DdlOp, Op};
16513
16514    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
16515    for entry in &catalog.tables {
16516        if !matches!(entry.state, TableState::Live) {
16517            continue;
16518        }
16519        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
16520        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
16521            Ok(manifest) => Some(manifest),
16522            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
16523            Err(error) => return Err(error),
16524        };
16525        let flushed_epoch = if let Some(manifest) = manifest {
16526            if manifest.table_id != entry.table_id {
16527                return Err(MongrelError::Conflict(format!(
16528                    "catalog table {} storage identity mismatch",
16529                    entry.table_id
16530                )));
16531            }
16532            if (manifest.schema_id != entry.schema.schema_id
16533                && !reconciled_table_ids.contains(&entry.table_id))
16534                || manifest.flushed_epoch > manifest.current_epoch
16535                || manifest.global_idx_epoch > manifest.current_epoch
16536                || manifest.next_row_id == u64::MAX
16537                || manifest.auto_inc_next < 0
16538                || manifest.auto_inc_next == i64::MAX
16539                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
16540            {
16541                return Err(MongrelError::InvalidArgument(format!(
16542                    "table {} manifest counters or schema identity are invalid",
16543                    entry.table_id
16544                )));
16545            }
16546            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
16547            crate::global_idx::read_durable_for(
16548                durable_root,
16549                &relative_dir,
16550                entry.table_id,
16551                &entry.schema,
16552                idx_dek.as_deref(),
16553            )?;
16554            let mut run_ids = HashSet::new();
16555            let mut maximum_row_id = None::<u64>;
16556            for run in &manifest.runs {
16557                if run.run_id >= u64::MAX as u128
16558                    || run.epoch_created > manifest.current_epoch
16559                    || !run_ids.insert(run.run_id)
16560                {
16561                    return Err(MongrelError::InvalidArgument(format!(
16562                        "table {} manifest contains an invalid or duplicate run id",
16563                        entry.table_id
16564                    )));
16565                }
16566                let relative = relative_dir
16567                    .join(crate::engine::RUNS_DIR)
16568                    .join(format!("r-{}.sr", run.run_id as u64));
16569                let file = durable_root.open_regular(&relative)?;
16570                let mut reader = crate::sorted_run::RunReader::open_file(
16571                    file,
16572                    entry.schema.clone(),
16573                    kek.clone(),
16574                )?;
16575                let header = reader.header();
16576                if header.run_id != run.run_id
16577                    || header.level != run.level
16578                    || header.row_count != run.row_count
16579                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
16580                    || header.is_uniform_epoch() && header.epoch_created != 0
16581                    || header.schema_id > entry.schema.schema_id
16582                {
16583                    return Err(MongrelError::InvalidArgument(format!(
16584                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
16585                        entry.table_id,
16586                        run.run_id,
16587                        header.run_id,
16588                        header.level,
16589                        header.row_count,
16590                        header.epoch_created,
16591                        header.schema_id,
16592                        run.run_id,
16593                        run.level,
16594                        run.row_count,
16595                        run.epoch_created,
16596                        entry.schema.schema_id,
16597                    )));
16598                }
16599                if header.row_count != 0 {
16600                    maximum_row_id = Some(
16601                        maximum_row_id
16602                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
16603                    );
16604                }
16605                reader.validate_all_pages()?;
16606            }
16607            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
16608                return Err(MongrelError::InvalidArgument(format!(
16609                    "table {} next_row_id does not advance beyond persisted rows",
16610                    entry.table_id
16611                )));
16612            }
16613            for run in &manifest.retiring {
16614                if run.run_id >= u64::MAX as u128
16615                    || run.retire_epoch > manifest.current_epoch
16616                    || !run_ids.insert(run.run_id)
16617                {
16618                    return Err(MongrelError::InvalidArgument(format!(
16619                        "table {} manifest contains an invalid or aliased retired run",
16620                        entry.table_id
16621                    )));
16622                }
16623            }
16624            manifest.flushed_epoch
16625        } else {
16626            if !recovered_table_ids.contains(&entry.table_id) {
16627                return Err(MongrelError::NotFound(format!(
16628                    "live table {} manifest is missing",
16629                    entry.table_id
16630                )));
16631            }
16632            0
16633        };
16634        tables.insert(
16635            entry.table_id,
16636            RecoveryValidationTable {
16637                schema: entry.schema.clone(),
16638                flushed_epoch,
16639            },
16640        );
16641    }
16642
16643    let committed = records
16644        .iter()
16645        .filter_map(|record| match record.op {
16646            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
16647            _ => None,
16648        })
16649        .collect::<HashMap<_, _>>();
16650    let mut run_ids = HashSet::new();
16651    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
16652    for record in records {
16653        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
16654            continue;
16655        };
16656        match &record.op {
16657            Op::Put { table_id, rows } => {
16658                let table = validate_recovery_data_table_plan(
16659                    catalog,
16660                    &tables,
16661                    *table_id,
16662                    commit_epoch,
16663                    record.seq.0,
16664                )?;
16665                let decoded: Vec<crate::memtable::Row> =
16666                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
16667                        offset: record.seq.0,
16668                        reason: format!(
16669                            "committed Put payload for transaction {} could not be decoded: {error}",
16670                            record.txn_id
16671                        ),
16672                    })?;
16673                if let Some(table) = table {
16674                    for row in &decoded {
16675                        if !recovered_row_ids
16676                            .entry(*table_id)
16677                            .or_default()
16678                            .insert(row.row_id.0)
16679                        {
16680                            return Err(MongrelError::CorruptWal {
16681                                offset: record.seq.0,
16682                                reason: format!(
16683                                    "committed WAL repeats recovered row id {} for table {table_id}",
16684                                    row.row_id.0
16685                                ),
16686                            });
16687                        }
16688                        validate_recovered_row(&table.schema, row)?;
16689                    }
16690                }
16691            }
16692            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
16693                validate_recovery_data_table_plan(
16694                    catalog,
16695                    &tables,
16696                    *table_id,
16697                    commit_epoch,
16698                    record.seq.0,
16699                )?;
16700            }
16701            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
16702            Op::Ddl(DdlOp::ResetExternalTableState {
16703                name,
16704                generation_epoch,
16705            }) => {
16706                if *generation_epoch != commit_epoch {
16707                    return Err(MongrelError::CorruptWal {
16708                        offset: record.seq.0,
16709                        reason: format!(
16710                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
16711                        ),
16712                    });
16713                }
16714                validate_recovered_external_name(name)?;
16715            }
16716            Op::TxnCommit { added_runs, .. } => {
16717                for added in added_runs {
16718                    let Some(table) = validate_recovery_data_table_plan(
16719                        catalog,
16720                        &tables,
16721                        added.table_id,
16722                        commit_epoch,
16723                        record.seq.0,
16724                    )?
16725                    else {
16726                        continue;
16727                    };
16728                    if added.run_id >= u64::MAX as u128
16729                        || !run_ids.insert((added.table_id, added.run_id))
16730                    {
16731                        return Err(MongrelError::CorruptWal {
16732                            offset: record.seq.0,
16733                            reason: format!(
16734                                "duplicate or invalid recovered run {} for table {}",
16735                                added.run_id, added.table_id
16736                            ),
16737                        });
16738                    }
16739                    if commit_epoch <= table.flushed_epoch {
16740                        continue;
16741                    }
16742                    validate_planned_spilled_run(
16743                        durable_root,
16744                        record.txn_id,
16745                        commit_epoch,
16746                        added,
16747                        &table.schema,
16748                        kek.clone(),
16749                    )?;
16750                }
16751            }
16752            _ => {}
16753        }
16754    }
16755    Ok(())
16756}
16757
16758fn validate_recovery_data_table_plan<'a>(
16759    catalog: &Catalog,
16760    tables: &'a HashMap<u64, RecoveryValidationTable>,
16761    table_id: u64,
16762    commit_epoch: u64,
16763    offset: u64,
16764) -> Result<Option<&'a RecoveryValidationTable>> {
16765    let entry = catalog
16766        .tables
16767        .iter()
16768        .find(|entry| entry.table_id == table_id)
16769        .ok_or_else(|| MongrelError::CorruptWal {
16770            offset,
16771            reason: format!("committed record references unknown table {table_id}"),
16772        })?;
16773    if commit_epoch < entry.created_epoch {
16774        return Err(MongrelError::CorruptWal {
16775            offset,
16776            reason: format!(
16777                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16778                entry.created_epoch
16779            ),
16780        });
16781    }
16782    match entry.state {
16783        TableState::Dropped { at_epoch } => {
16784            let abandoned =
16785                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16786            if commit_epoch >= at_epoch && !abandoned {
16787                return Err(MongrelError::CorruptWal {
16788                    offset,
16789                    reason: format!(
16790                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16791                    ),
16792                });
16793            }
16794            Ok(None)
16795        }
16796        TableState::Live => {
16797            tables
16798                .get(&table_id)
16799                .map(Some)
16800                .ok_or_else(|| MongrelError::CorruptWal {
16801                    offset,
16802                    reason: format!("live table {table_id} has no recovery plan"),
16803                })
16804        }
16805        TableState::Building { .. } => Err(MongrelError::CorruptWal {
16806            offset,
16807            reason: format!("building table {table_id} was not normalized before recovery"),
16808        }),
16809    }
16810}
16811
16812fn validate_planned_spilled_run(
16813    root: &crate::durable_file::DurableRoot,
16814    txn_id: u64,
16815    commit_epoch: u64,
16816    added: &crate::wal::AddedRun,
16817    schema: &Schema,
16818    kek: Option<Arc<crate::encryption::Kek>>,
16819) -> Result<()> {
16820    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
16821    let destination = table
16822        .join(crate::engine::RUNS_DIR)
16823        .join(format!("r-{}.sr", added.run_id as u64));
16824    let pending = table
16825        .join("_txn")
16826        .join(txn_id.to_string())
16827        .join(format!("r-{}.sr", added.run_id as u64));
16828    let file = match root.open_regular(&destination) {
16829        Ok(file) => file,
16830        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
16831            root.open_regular(&pending).map_err(|pending_error| {
16832                if pending_error.kind() == std::io::ErrorKind::NotFound {
16833                    MongrelError::CorruptWal {
16834                        offset: commit_epoch,
16835                        reason: format!(
16836                            "committed spilled run {} for transaction {txn_id} is missing",
16837                            added.run_id
16838                        ),
16839                    }
16840                } else {
16841                    pending_error.into()
16842                }
16843            })?
16844        }
16845        Err(error) => return Err(error.into()),
16846    };
16847    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
16848    let header = reader.header();
16849    if header.run_id != added.run_id
16850        || header.content_hash != added.content_hash
16851        || header.row_count != added.row_count
16852        || header.level != added.level
16853        || header.min_row_id != added.min_row_id
16854        || header.max_row_id != added.max_row_id
16855        || header.schema_id != schema.schema_id
16856        || !header.is_uniform_epoch()
16857        || header.epoch_created != 0
16858    {
16859        return Err(MongrelError::CorruptWal {
16860            offset: commit_epoch,
16861            reason: format!(
16862                "committed spilled run {} metadata differs from WAL",
16863                added.run_id
16864            ),
16865        });
16866    }
16867    reader.validate_all_pages()?;
16868    Ok(())
16869}
16870
16871/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
16872///
16873/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
16874/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
16875/// 2 applies each committed data record (Put/Delete) to its table at the commit
16876/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
16877/// durable in a sorted run). Finally the shared epoch authority is raised to the
16878/// max committed epoch so the next commit continues monotonically.
16879/// The staged-write payload contract of a distributed-transaction write
16880/// intent (spec section 12.8). A participant in two-phase commit stages its
16881/// writes as opaque intent payloads (`WriteIntent::value_ref` in
16882/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
16883/// this engine-defined encoding is the whole contract. At prepare time the
16884/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
16885/// committed resolution they are applied through
16886/// [`Database::apply_staged_txn_writes`].
16887///
16888/// The encoding is bincode over this enum (the same codec the WAL frame
16889/// payloads use); discriminants are never reused.
16890#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16891pub enum StagedTxnWrite {
16892    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
16893    /// identical payload shape an `Op::Put` WAL record carries). Row commit
16894    /// epochs are placeholders — the resolution apply restamps every row at
16895    /// the synthetic commit epoch.
16896    Put {
16897        /// The mounted table the rows target.
16898        table_id: u64,
16899        /// Bincode `Vec<crate::memtable::Row>`.
16900        rows: Vec<u8>,
16901    },
16902    /// Staged row deletes by row id.
16903    Delete {
16904        /// The mounted table the deletes target.
16905        table_id: u64,
16906        /// Row ids (`crate::RowId` values) to delete.
16907        row_ids: Vec<u64>,
16908    },
16909}
16910
16911impl StagedTxnWrite {
16912    /// Serializes deterministically (bincode over the enum).
16913    pub fn encode(&self) -> Result<Vec<u8>> {
16914        Ok(bincode::serialize(self)?)
16915    }
16916
16917    /// Decodes one staged-write payload, failing closed on malformed input.
16918    pub fn decode(bytes: &[u8]) -> Result<Self> {
16919        Ok(bincode::deserialize(bytes)?)
16920    }
16921}
16922
16923/// Leader-side spill translation for the replicated write path (spec section
16924/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
16925///
16926/// A transaction whose staged puts exceed the spill threshold commits with
16927/// its rows in a leader-local sorted run: the commit marker's `added_runs`
16928/// links the run file and the rows also ride the WAL as logical
16929/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
16930/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
16931/// and because the raft entry is already quorum-committed, an apply-time
16932/// rejection wedges the whole group's apply stream. The leader therefore
16933/// translates the staged record sequence **before proposal**:
16934///
16935/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
16936///   (identical row bytes; recovery restamps the rows at the commit epoch),
16937/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
16938///   reach a replica),
16939/// - every other record passes through byte-identical.
16940///
16941/// The standalone commit path is untouched: the leader's own WAL keeps the
16942/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
16943/// links the run; this function reads but never mutates its input.
16944///
16945/// Translation is total for any sequence the commit sequencer actually
16946/// produced. As a fail-closed guard against malformed or truncated captures,
16947/// the sequence is structurally validated (one transaction, exactly one
16948/// trailing commit marker), every spill payload must decode, and every linked
16949/// run's rows must be provably present as logical records (the row-id range
16950/// covers `row_count` rows); a violation rejects the proposal with
16951/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
16952/// post-commit. (Taxonomy: `InvalidArgument` maps to
16953/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
16954/// disagreement that is never retried unchanged. The normative spec category
16955/// for "the commit was not applied; only a fresh transaction may succeed" is
16956/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
16957/// `error.rs`, which is outside this change's file scope — tracked as a
16958/// follow-up.)
16959pub fn translate_records_for_replication(
16960    records: &[crate::wal::Record],
16961) -> Result<Vec<crate::wal::Record>> {
16962    use crate::wal::Op;
16963
16964    // Structural validation mirrors `apply_replicated_records`: one
16965    // transaction, exactly one commit marker, at the tail.
16966    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
16967        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
16968    })?;
16969    if records.iter().any(|record| record.txn_id != txn_id) {
16970        return Err(MongrelError::InvalidArgument(
16971            "replicated transaction payload mixes transaction ids".into(),
16972        ));
16973    }
16974    let commits = records
16975        .iter()
16976        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
16977        .count();
16978    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
16979        return Err(MongrelError::InvalidArgument(
16980            "replicated transaction payload must end in exactly one commit marker".into(),
16981        ));
16982    }
16983
16984    // Decode every logical spill payload now: a payload that cannot decode
16985    // here would fail every replica's apply identically — reject at propose
16986    // time instead.
16987    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
16988    for record in records {
16989        if let Op::SpilledRows { table_id, rows } = &record.op {
16990            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
16991                MongrelError::InvalidArgument(format!(
16992                    "spilled row payload for table {table_id} cannot decode for replication: \
16993                         {error}"
16994                ))
16995            })?;
16996            spilled_rows.entry(*table_id).or_default().extend(chunk);
16997        }
16998    }
16999
17000    // Coverage proof: every run the commit marker links must have its full
17001    // row content present as logical records, or replicas would silently
17002    // lose those rows.
17003    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
17004        unreachable!("one trailing commit marker validated above");
17005    };
17006    for run in added_runs {
17007        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
17008            MongrelError::InvalidArgument(format!(
17009                "commit links spilled run {} for table {} but carries no logical row records \
17010                 for it",
17011                run.run_id, run.table_id
17012            ))
17013        })?;
17014        let covered = rows
17015            .iter()
17016            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
17017            .count() as u64;
17018        if covered != run.row_count {
17019            return Err(MongrelError::InvalidArgument(format!(
17020                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
17021                 row records cover {} rows",
17022                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
17023            )));
17024        }
17025    }
17026
17027    // Translate: spill payloads become ordinary puts; the commit marker no
17028    // longer references leader-local run files.
17029    let translated = records
17030        .iter()
17031        .map(|record| {
17032            let op = match &record.op {
17033                Op::SpilledRows { table_id, rows } => Op::Put {
17034                    table_id: *table_id,
17035                    rows: rows.clone(),
17036                },
17037                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
17038                    epoch: *epoch,
17039                    added_runs: Vec::new(),
17040                },
17041                op => op.clone(),
17042            };
17043            crate::wal::Record::new(record.seq, record.txn_id, op)
17044        })
17045        .collect();
17046    Ok(translated)
17047}
17048
17049fn recover_shared_wal(
17050    durable_root: &crate::durable_file::DurableRoot,
17051    tables: &HashMap<u64, TableHandle>,
17052    catalog: &Catalog,
17053    epoch: &EpochAuthority,
17054    records: &[crate::wal::Record],
17055) -> Result<()> {
17056    use crate::memtable::Row;
17057    use crate::wal::{DdlOp, Op};
17058
17059    // Pass 1: committed-txn outcomes + collect spilled-run info.
17060    let mut committed: HashMap<u64, u64> = HashMap::new();
17061    // Physical HLC micros from Op::CommitTimestamp, keyed by txn_id (P0.5).
17062    let mut commit_ts_by_txn: HashMap<u64, mongreldb_types::hlc::HlcTimestamp> = HashMap::new();
17063    let mut spilled_to_link: Vec<(
17064        u64, /*txn_id*/
17065        u64, /*epoch*/
17066        Vec<crate::wal::AddedRun>,
17067    )> = Vec::new();
17068    for r in records {
17069        match &r.op {
17070            Op::CommitTimestamp { unix_nanos } => {
17071                commit_ts_by_txn.insert(
17072                    r.txn_id,
17073                    mongreldb_types::hlc::HlcTimestamp {
17074                        physical_micros: unix_nanos / 1_000,
17075                        logical: 0,
17076                        node_tiebreaker: 0,
17077                    },
17078                );
17079            }
17080            Op::TxnCommit {
17081                epoch: ce,
17082                ref added_runs,
17083            } => {
17084                committed.insert(r.txn_id, *ce);
17085                if !added_runs.is_empty() {
17086                    spilled_to_link.push((r.txn_id, *ce, added_runs.clone()));
17087                }
17088            }
17089            _ => {}
17090        }
17091    }
17092    for record in records {
17093        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
17094            continue;
17095        };
17096        match &record.op {
17097            Op::Put { table_id, .. }
17098            | Op::Delete { table_id, .. }
17099            | Op::TruncateTable { table_id } => {
17100                validate_recovered_data_table(
17101                    catalog,
17102                    tables,
17103                    *table_id,
17104                    commit_epoch,
17105                    record.seq.0,
17106                )?;
17107            }
17108            Op::TxnCommit { added_runs, .. } => {
17109                for run in added_runs {
17110                    validate_recovered_data_table(
17111                        catalog,
17112                        tables,
17113                        run.table_id,
17114                        commit_epoch,
17115                        record.seq.0,
17116                    )?;
17117                }
17118            }
17119            _ => {}
17120        }
17121    }
17122    let truncated_transactions: HashSet<(u64, u64)> = records
17123        .iter()
17124        .filter_map(|record| {
17125            committed.get(&record.txn_id)?;
17126            match record.op {
17127                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
17128                _ => None,
17129            }
17130        })
17131        .collect();
17132
17133    // Pass 2: stage data per table, gated by flushed_epoch.
17134    enum ExternalRecoveryAction {
17135        Write { name: String, state: Vec<u8> },
17136        Reset { name: String },
17137    }
17138    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
17139    let mut external_actions = Vec::new();
17140    let mut max_epoch = epoch.visible().0;
17141    for r in records.iter().cloned() {
17142        let Some(&ce) = committed.get(&r.txn_id) else {
17143            continue; // aborted / in-flight — discard
17144        };
17145        let commit_epoch = Epoch(ce);
17146        max_epoch = max_epoch.max(ce);
17147        match r.op {
17148            Op::Put { table_id, rows } => {
17149                // Skip if this table already flushed past the commit epoch.
17150                let skip = tables
17151                    .get(&table_id)
17152                    .map(|h| h.lock().flushed_epoch() >= ce)
17153                    .unwrap_or(true);
17154                if skip {
17155                    continue;
17156                }
17157                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
17158                    MongrelError::CorruptWal {
17159                        offset: r.seq.0,
17160                        reason: format!(
17161                            "committed Put payload for transaction {} could not be decoded: {error}",
17162                            r.txn_id
17163                        ),
17164                    }
17165                })?;
17166                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
17167                // at pending_epoch which equals the commit epoch, but be robust).
17168                // P0.5: prefer the durable CommitTimestamp HLC for this txn when
17169                // present so recovery restores HLC-authoritative versions.
17170                let txn_commit_ts = commit_ts_by_txn.get(&r.txn_id).copied();
17171                let rows: Vec<Row> = rows
17172                    .into_iter()
17173                    .map(|mut row| {
17174                        row.committed_epoch = commit_epoch;
17175                        if row.commit_ts.is_none() {
17176                            row.commit_ts = txn_commit_ts;
17177                        }
17178                        row
17179                    })
17180                    .collect();
17181                let entry = stage
17182                    .entry(table_id)
17183                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17184                entry.0.extend(rows);
17185                entry.3 = commit_epoch;
17186            }
17187            Op::Delete { table_id, row_ids } => {
17188                let skip = tables
17189                    .get(&table_id)
17190                    .map(|h| h.lock().flushed_epoch() >= ce)
17191                    .unwrap_or(true);
17192                if skip {
17193                    continue;
17194                }
17195                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
17196                let entry = stage
17197                    .entry(table_id)
17198                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17199                entry.1.extend(dels);
17200                entry.3 = commit_epoch;
17201            }
17202            Op::TruncateTable { table_id } => {
17203                let skip = tables
17204                    .get(&table_id)
17205                    .map(|h| h.lock().flushed_epoch() >= ce)
17206                    .unwrap_or(true);
17207                if skip {
17208                    continue;
17209                }
17210                stage.insert(
17211                    table_id,
17212                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
17213                );
17214            }
17215            Op::ExternalTableState { name, state } => {
17216                let current_generation = catalog
17217                    .external_tables
17218                    .iter()
17219                    .find(|entry| entry.name == name)
17220                    .map(|entry| entry.created_epoch);
17221                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
17222                    validate_recovered_external_name(&name)?;
17223                    external_actions.push(ExternalRecoveryAction::Write { name, state });
17224                }
17225            }
17226            Op::Ddl(DdlOp::ResetExternalTableState {
17227                name,
17228                generation_epoch,
17229            }) => {
17230                if generation_epoch != ce {
17231                    return Err(MongrelError::CorruptWal {
17232                        offset: r.seq.0,
17233                        reason: format!(
17234                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
17235                    ),
17236                    });
17237                }
17238                validate_recovered_external_name(&name)?;
17239                external_actions.push(ExternalRecoveryAction::Reset { name });
17240            }
17241            Op::Flush { .. }
17242            | Op::TxnCommit { .. }
17243            | Op::TxnAbort
17244            | Op::Ddl(_)
17245            | Op::BeforeImage { .. }
17246            | Op::CommitTimestamp { .. }
17247            | Op::SpilledRows { .. } => {}
17248        }
17249    }
17250    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
17251        added_runs.retain(|added| {
17252            tables
17253                .get(&added.table_id)
17254                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
17255        });
17256    }
17257    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
17258    validate_recovery_table_stages(tables, &stage)?;
17259    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
17260
17261    // All WAL payloads, catalog generations, table stages, and immutable run
17262    // identities have now been validated. Only this application phase mutates
17263    // the database tree.
17264    for action in external_actions {
17265        match action {
17266            ExternalRecoveryAction::Write { name, state } => {
17267                write_external_state_file(durable_root, &name, &state)?;
17268            }
17269            ExternalRecoveryAction::Reset { name } => {
17270                durable_root.create_directory_all(VTAB_DIR)?;
17271                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
17272            }
17273        }
17274    }
17275    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
17276        let Some(handle) = tables.get(&table_id) else {
17277            continue;
17278        };
17279        let mut t = handle.lock();
17280        if let Some(epoch) = truncate_epoch {
17281            t.apply_truncate(epoch);
17282        }
17283        t.recover_apply(rows, deletes)?;
17284        // The WAL can be newer than the copied/persisted manifest after a
17285        // crash or replication apply. Rebuild O(1) count metadata from the
17286        // recovered state before endorsing the commit epoch in the manifest.
17287        let rows = t.visible_rows(Snapshot::unbounded())?;
17288        t.live_count = rows.len() as u64;
17289        // Recovery can replay older row commits while a newer spilled run is
17290        // already linked by the copied manifest. Never move that manifest's
17291        // epoch behind its existing run references.
17292        t.persist_manifest(table_epoch.max(epoch.visible()))?;
17293    }
17294
17295    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
17296    // between TxnCommit sync and the publish phase leaves the run in
17297    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
17298    for (txn_id, ce, added_runs) in &spilled_to_link {
17299        for ar in added_runs {
17300            let Some(handle) = tables.get(&ar.table_id) else {
17301                continue;
17302            };
17303            let mut t = handle.lock();
17304            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
17305            let destination = table_dir
17306                .join(crate::engine::RUNS_DIR)
17307                .join(format!("r-{}.sr", ar.run_id));
17308            match durable_root.open_regular(&destination) {
17309                Ok(_) => {}
17310                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17311                    let pending = table_dir
17312                        .join("_txn")
17313                        .join(txn_id.to_string())
17314                        .join(format!("r-{}.sr", ar.run_id));
17315                    durable_root.rename_file_new(&pending, &destination)?;
17316                }
17317                Err(error) => return Err(error.into()),
17318            }
17319            // Only link a run whose file is actually present, and never re-link
17320            // one the publish phase already persisted into the manifest (which is
17321            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
17322            // until segment GC). `recover_spilled_run` is idempotent + reconciles
17323            // `live_count`/indexes only when the run is genuinely new.
17324            let linked = t.recover_spilled_run(crate::manifest::RunRef {
17325                run_id: ar.run_id,
17326                level: ar.level,
17327                epoch_created: *ce,
17328                row_count: ar.row_count,
17329            });
17330            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
17331            if replaced {
17332                t.set_flushed_epoch(Epoch(*ce));
17333            }
17334            if linked || replaced {
17335                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
17336            }
17337        }
17338    }
17339
17340    epoch.advance_recovered(Epoch(max_epoch));
17341    Ok(())
17342}
17343
17344fn reconcile_recovered_table_metadata(
17345    tables: &HashMap<u64, TableHandle>,
17346    epoch: Epoch,
17347) -> Result<()> {
17348    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
17349    table_ids.sort_unstable();
17350    let mut plans = Vec::with_capacity(table_ids.len());
17351    for table_id in &table_ids {
17352        let handle = tables.get(table_id).ok_or_else(|| {
17353            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17354        })?;
17355        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
17356    }
17357    // Every table's data and metadata have been decoded successfully. Publish
17358    // repairs only after the complete database-wide plan is known valid.
17359    for (table_id, plan) in plans {
17360        let handle = tables.get(&table_id).ok_or_else(|| {
17361            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17362        })?;
17363        handle.lock().apply_recovered_metadata(plan, epoch)?;
17364    }
17365    Ok(())
17366}
17367
17368fn validate_recovered_external_name(name: &str) -> Result<()> {
17369    if name.is_empty()
17370        || !name.chars().all(|character| {
17371            character.is_ascii_alphanumeric() || character == '_' || character == '-'
17372        })
17373    {
17374        return Err(MongrelError::CorruptWal {
17375            offset: 0,
17376            reason: format!("unsafe recovered external-table name {name:?}"),
17377        });
17378    }
17379    Ok(())
17380}
17381
17382fn validate_recovery_table_stages(
17383    tables: &HashMap<u64, TableHandle>,
17384    stages: &HashMap<u64, RecoveryTableStage>,
17385) -> Result<()> {
17386    for (table_id, (rows, _, _, _)) in stages {
17387        let handle = tables
17388            .get(table_id)
17389            .ok_or_else(|| MongrelError::CorruptWal {
17390                offset: *table_id,
17391                reason: format!("recovery stage references unmounted table {table_id}"),
17392            })?;
17393        let table = handle.lock();
17394        // Force all existing immutable runs through their integrity/decode path
17395        // before any other table manifest can be changed.
17396        table.visible_rows(Snapshot::unbounded())?;
17397        for row in rows {
17398            validate_recovered_row(table.schema(), row)?;
17399        }
17400    }
17401    Ok(())
17402}
17403
17404fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
17405    if row.deleted || row.row_id.0 == u64::MAX {
17406        return Err(MongrelError::CorruptWal {
17407            offset: row.row_id.0,
17408            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
17409        });
17410    }
17411    let cells = row
17412        .columns
17413        .iter()
17414        .map(|(column, value)| (*column, value.clone()))
17415        .collect::<Vec<_>>();
17416    schema
17417        .validate_persisted_values(&cells)
17418        .map_err(|error| MongrelError::CorruptWal {
17419            offset: row.row_id.0,
17420            reason: format!("recovered row violates table schema: {error}"),
17421        })?;
17422    if schema.auto_increment_column().is_some_and(|column| {
17423        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
17424    }) {
17425        return Err(MongrelError::CorruptWal {
17426            offset: row.row_id.0,
17427            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
17428        });
17429    }
17430    Ok(())
17431}
17432
17433fn validate_recovery_spilled_runs(
17434    root: &crate::durable_file::DurableRoot,
17435    tables: &HashMap<u64, TableHandle>,
17436    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
17437) -> Result<()> {
17438    let mut identities = HashSet::new();
17439    for (txn_id, commit_epoch, added_runs) in spilled {
17440        for added in added_runs {
17441            if added.run_id >= u64::MAX as u128 {
17442                return Err(MongrelError::CorruptWal {
17443                    offset: *commit_epoch,
17444                    reason: format!(
17445                        "recovered run id {} exceeds the on-disk namespace",
17446                        added.run_id
17447                    ),
17448                });
17449            }
17450            let Some(handle) = tables.get(&added.table_id) else {
17451                continue;
17452            };
17453            if !identities.insert((added.table_id, added.run_id)) {
17454                return Err(MongrelError::CorruptWal {
17455                    offset: *commit_epoch,
17456                    reason: format!(
17457                        "duplicate recovered run {} for table {}",
17458                        added.run_id, added.table_id
17459                    ),
17460                });
17461            }
17462            let table = handle.lock();
17463            validate_planned_spilled_run(
17464                root,
17465                *txn_id,
17466                *commit_epoch,
17467                added,
17468                table.schema(),
17469                table.kek(),
17470            )?;
17471        }
17472    }
17473    Ok(())
17474}
17475
17476fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
17477    match condition {
17478        ProcedureCondition::Pk { .. } => {
17479            if schema.primary_key().is_none() {
17480                return Err(MongrelError::InvalidArgument(
17481                    "procedure condition Pk references a table without a primary key".into(),
17482                ));
17483            }
17484        }
17485        ProcedureCondition::BitmapEq { column_id, .. }
17486        | ProcedureCondition::BitmapIn { column_id, .. }
17487        | ProcedureCondition::Range { column_id, .. }
17488        | ProcedureCondition::RangeF64 { column_id, .. }
17489        | ProcedureCondition::IsNull { column_id }
17490        | ProcedureCondition::IsNotNull { column_id }
17491        | ProcedureCondition::FmContains { column_id, .. } => {
17492            validate_column_id(*column_id, schema)?;
17493        }
17494    }
17495    Ok(())
17496}
17497
17498fn bind_procedure_args(
17499    procedure: &StoredProcedure,
17500    mut args: HashMap<String, crate::Value>,
17501) -> Result<HashMap<String, crate::Value>> {
17502    let mut out = HashMap::new();
17503    for param in &procedure.params {
17504        let value = match args.remove(&param.name) {
17505            Some(value) => value,
17506            None => param.default.clone().ok_or_else(|| {
17507                MongrelError::InvalidArgument(format!(
17508                    "missing required procedure parameter {:?}",
17509                    param.name
17510                ))
17511            })?,
17512        };
17513        if !param.nullable && matches!(value, crate::Value::Null) {
17514            return Err(MongrelError::InvalidArgument(format!(
17515                "procedure parameter {:?} must not be NULL",
17516                param.name
17517            )));
17518        }
17519        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
17520            return Err(MongrelError::InvalidArgument(format!(
17521                "procedure parameter {:?} has wrong type",
17522                param.name
17523            )));
17524        }
17525        out.insert(param.name.clone(), value);
17526    }
17527    if let Some(extra) = args.keys().next() {
17528        return Err(MongrelError::InvalidArgument(format!(
17529            "unknown procedure parameter {extra:?}"
17530        )));
17531    }
17532    Ok(out)
17533}
17534
17535fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
17536    matches!(
17537        (value, ty),
17538        (crate::Value::Bool(_), crate::TypeId::Bool)
17539            | (crate::Value::Int64(_), crate::TypeId::Int8)
17540            | (crate::Value::Int64(_), crate::TypeId::Int16)
17541            | (crate::Value::Int64(_), crate::TypeId::Int32)
17542            | (crate::Value::Int64(_), crate::TypeId::Int64)
17543            | (crate::Value::Int64(_), crate::TypeId::UInt8)
17544            | (crate::Value::Int64(_), crate::TypeId::UInt16)
17545            | (crate::Value::Int64(_), crate::TypeId::UInt32)
17546            | (crate::Value::Int64(_), crate::TypeId::UInt64)
17547            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
17548            | (crate::Value::Int64(_), crate::TypeId::Date32)
17549            | (crate::Value::Float64(_), crate::TypeId::Float32)
17550            | (crate::Value::Float64(_), crate::TypeId::Float64)
17551            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
17552            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
17553            | (
17554                crate::Value::GeneratedEmbedding(_),
17555                crate::TypeId::Embedding { .. }
17556            )
17557    )
17558}
17559
17560fn eval_cells(
17561    cells: &[crate::procedure::ProcedureCell],
17562    args: &HashMap<String, crate::Value>,
17563    outputs: &HashMap<String, ProcedureCallOutput>,
17564) -> Result<Vec<(u16, crate::Value)>> {
17565    cells
17566        .iter()
17567        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
17568        .collect()
17569}
17570
17571fn eval_condition(
17572    condition: &ProcedureCondition,
17573    args: &HashMap<String, crate::Value>,
17574    outputs: &HashMap<String, ProcedureCallOutput>,
17575) -> Result<crate::Condition> {
17576    Ok(match condition {
17577        ProcedureCondition::Pk { value } => {
17578            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
17579        }
17580        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
17581            column_id: *column_id,
17582            value: eval_value(value, args, outputs)?.encode_key(),
17583        },
17584        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
17585            column_id: *column_id,
17586            values: values
17587                .iter()
17588                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
17589                .collect::<Result<Vec<_>>>()?,
17590        },
17591        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
17592            column_id: *column_id,
17593            lo: expect_i64(eval_value(lo, args, outputs)?)?,
17594            hi: expect_i64(eval_value(hi, args, outputs)?)?,
17595        },
17596        ProcedureCondition::RangeF64 {
17597            column_id,
17598            lo,
17599            lo_inclusive,
17600            hi,
17601            hi_inclusive,
17602        } => crate::Condition::RangeF64 {
17603            column_id: *column_id,
17604            lo: expect_f64(eval_value(lo, args, outputs)?)?,
17605            lo_inclusive: *lo_inclusive,
17606            hi: expect_f64(eval_value(hi, args, outputs)?)?,
17607            hi_inclusive: *hi_inclusive,
17608        },
17609        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
17610            column_id: *column_id,
17611        },
17612        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
17613            column_id: *column_id,
17614        },
17615        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
17616            column_id: *column_id,
17617            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
17618        },
17619    })
17620}
17621
17622fn eval_value(
17623    value: &ProcedureValue,
17624    args: &HashMap<String, crate::Value>,
17625    outputs: &HashMap<String, ProcedureCallOutput>,
17626) -> Result<crate::Value> {
17627    match value {
17628        ProcedureValue::Literal(value) => Ok(value.clone()),
17629        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
17630            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17631        }),
17632        ProcedureValue::StepScalar(id) => match outputs.get(id) {
17633            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
17634            _ => Err(MongrelError::InvalidArgument(format!(
17635                "procedure step {id:?} did not return a scalar"
17636            ))),
17637        },
17638        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
17639            Err(MongrelError::InvalidArgument(
17640                "row-valued procedure reference cannot be used as a scalar".into(),
17641            ))
17642        }
17643        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
17644            "structured procedure value cannot be used as a scalar cell".into(),
17645        )),
17646    }
17647}
17648
17649fn eval_return_output(
17650    value: &ProcedureValue,
17651    args: &HashMap<String, crate::Value>,
17652    outputs: &HashMap<String, ProcedureCallOutput>,
17653) -> Result<ProcedureCallOutput> {
17654    match value {
17655        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
17656        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
17657            args.get(name).cloned().ok_or_else(|| {
17658                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17659            })?,
17660        )),
17661        ProcedureValue::StepRows(id)
17662        | ProcedureValue::StepRow(id)
17663        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
17664            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
17665        }),
17666        ProcedureValue::Object(fields) => {
17667            let mut out = Vec::with_capacity(fields.len());
17668            for (name, value) in fields {
17669                out.push((name.clone(), eval_return_output(value, args, outputs)?));
17670            }
17671            Ok(ProcedureCallOutput::Object(out))
17672        }
17673        ProcedureValue::Array(values) => {
17674            let mut out = Vec::with_capacity(values.len());
17675            for value in values {
17676                out.push(eval_return_output(value, args, outputs)?);
17677            }
17678            Ok(ProcedureCallOutput::Array(out))
17679        }
17680    }
17681}
17682
17683fn expect_i64(value: crate::Value) -> Result<i64> {
17684    match value {
17685        crate::Value::Int64(value) => Ok(value),
17686        _ => Err(MongrelError::InvalidArgument(
17687            "procedure value must be Int64".into(),
17688        )),
17689    }
17690}
17691
17692fn expect_f64(value: crate::Value) -> Result<f64> {
17693    match value {
17694        crate::Value::Float64(value) => Ok(value),
17695        _ => Err(MongrelError::InvalidArgument(
17696            "procedure value must be Float64".into(),
17697        )),
17698    }
17699}
17700
17701fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
17702    match value {
17703        crate::Value::Bytes(value) => Ok(value),
17704        _ => Err(MongrelError::InvalidArgument(
17705            "procedure value must be Bytes".into(),
17706        )),
17707    }
17708}
17709
17710fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
17711    if schema.columns.iter().any(|c| c.id == column_id) {
17712        Ok(())
17713    } else {
17714        Err(MongrelError::InvalidArgument(format!(
17715            "unknown column id {column_id}"
17716        )))
17717    }
17718}
17719
17720fn trigger_matches_event(
17721    trigger: &StoredTrigger,
17722    event: &WriteEvent,
17723    cat: &Catalog,
17724) -> Result<bool> {
17725    if trigger.event != event.kind {
17726        return Ok(false);
17727    }
17728    let TriggerTarget::Table(target) = &trigger.target else {
17729        return Ok(false);
17730    };
17731    if target != &event.table {
17732        return Ok(false);
17733    }
17734    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
17735        let schema = &cat
17736            .live(target)
17737            .ok_or_else(|| {
17738                MongrelError::InvalidArgument(format!(
17739                    "trigger {:?} references unknown table {target:?}",
17740                    trigger.name
17741                ))
17742            })?
17743            .schema;
17744        let mut watched = Vec::with_capacity(trigger.update_of.len());
17745        for name in &trigger.update_of {
17746            let col = schema.column(name).ok_or_else(|| {
17747                MongrelError::InvalidArgument(format!(
17748                    "trigger {:?} references unknown UPDATE OF column {name:?}",
17749                    trigger.name
17750                ))
17751            })?;
17752            watched.push(col.id);
17753        }
17754        if !event
17755            .changed_columns
17756            .iter()
17757            .any(|column_id| watched.contains(column_id))
17758        {
17759            return Ok(false);
17760        }
17761    }
17762    Ok(true)
17763}
17764
17765fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
17766    let mut ids = std::collections::BTreeSet::new();
17767    if let Some(old) = old {
17768        ids.extend(old.columns.keys().copied());
17769    }
17770    if let Some(new) = new {
17771        ids.extend(new.columns.keys().copied());
17772    }
17773    ids.into_iter()
17774        .filter(|id| {
17775            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
17776        })
17777        .collect()
17778}
17779
17780fn eval_trigger_cells(
17781    cells: &[crate::trigger::TriggerCell],
17782    event: &WriteEvent,
17783    selected: Option<&TriggerRowImage>,
17784) -> Result<Vec<(u16, Value)>> {
17785    cells
17786        .iter()
17787        .map(|cell| {
17788            Ok((
17789                cell.column_id,
17790                eval_trigger_value(&cell.value, event, selected)?,
17791            ))
17792        })
17793        .collect()
17794}
17795
17796fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
17797    match expr {
17798        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
17799            Value::Bool(value) => Ok(value),
17800            Value::Null => Ok(false),
17801            other => Err(MongrelError::InvalidArgument(format!(
17802                "trigger WHEN value must be boolean, got {other:?}"
17803            ))),
17804        },
17805        TriggerExpr::Eq { left, right } => Ok(values_equal(
17806            &eval_trigger_value(left, event, None)?,
17807            &eval_trigger_value(right, event, None)?,
17808        )),
17809        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
17810            &eval_trigger_value(left, event, None)?,
17811            &eval_trigger_value(right, event, None)?,
17812        )),
17813        TriggerExpr::Lt { left, right } => match value_order(
17814            &eval_trigger_value(left, event, None)?,
17815            &eval_trigger_value(right, event, None)?,
17816        ) {
17817            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17818            None => Ok(false),
17819        },
17820        TriggerExpr::Lte { left, right } => match value_order(
17821            &eval_trigger_value(left, event, None)?,
17822            &eval_trigger_value(right, event, None)?,
17823        ) {
17824            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17825            None => Ok(false),
17826        },
17827        TriggerExpr::Gt { left, right } => match value_order(
17828            &eval_trigger_value(left, event, None)?,
17829            &eval_trigger_value(right, event, None)?,
17830        ) {
17831            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17832            None => Ok(false),
17833        },
17834        TriggerExpr::Gte { left, right } => match value_order(
17835            &eval_trigger_value(left, event, None)?,
17836            &eval_trigger_value(right, event, None)?,
17837        ) {
17838            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
17839            None => Ok(false),
17840        },
17841        TriggerExpr::IsNull(value) => Ok(matches!(
17842            eval_trigger_value(value, event, None)?,
17843            Value::Null
17844        )),
17845        TriggerExpr::IsNotNull(value) => Ok(!matches!(
17846            eval_trigger_value(value, event, None)?,
17847            Value::Null
17848        )),
17849        TriggerExpr::And { left, right } => {
17850            if !eval_trigger_expr(left, event)? {
17851                Ok(false)
17852            } else {
17853                Ok(eval_trigger_expr(right, event)?)
17854            }
17855        }
17856        TriggerExpr::Or { left, right } => {
17857            if eval_trigger_expr(left, event)? {
17858                Ok(true)
17859            } else {
17860                Ok(eval_trigger_expr(right, event)?)
17861            }
17862        }
17863        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
17864    }
17865}
17866
17867fn eval_trigger_condition(
17868    condition: &TriggerCondition,
17869    event: &WriteEvent,
17870    selected: &TriggerRowImage,
17871    schema: &Schema,
17872) -> Result<bool> {
17873    match condition {
17874        TriggerCondition::Pk { value } => {
17875            let pk = schema.primary_key().ok_or_else(|| {
17876                MongrelError::InvalidArgument(
17877                    "trigger condition Pk references a table without a primary key".into(),
17878                )
17879            })?;
17880            let lhs = eval_trigger_value(value, event, Some(selected))?;
17881            Ok(values_equal(
17882                &lhs,
17883                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
17884            ))
17885        }
17886        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
17887            selected.columns.get(column_id).unwrap_or(&Value::Null),
17888            &eval_trigger_value(value, event, Some(selected))?,
17889        )),
17890        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
17891            selected.columns.get(column_id).unwrap_or(&Value::Null),
17892            &eval_trigger_value(value, event, Some(selected))?,
17893        )),
17894        TriggerCondition::Lt { column_id, value } => match value_order(
17895            selected.columns.get(column_id).unwrap_or(&Value::Null),
17896            &eval_trigger_value(value, event, Some(selected))?,
17897        ) {
17898            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17899            None => Ok(false),
17900        },
17901        TriggerCondition::Lte { column_id, value } => match value_order(
17902            selected.columns.get(column_id).unwrap_or(&Value::Null),
17903            &eval_trigger_value(value, event, Some(selected))?,
17904        ) {
17905            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17906            None => Ok(false),
17907        },
17908        TriggerCondition::Gt { column_id, value } => match value_order(
17909            selected.columns.get(column_id).unwrap_or(&Value::Null),
17910            &eval_trigger_value(value, event, Some(selected))?,
17911        ) {
17912            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17913            None => Ok(false),
17914        },
17915        TriggerCondition::Gte { column_id, value } => match value_order(
17916            selected.columns.get(column_id).unwrap_or(&Value::Null),
17917            &eval_trigger_value(value, event, Some(selected))?,
17918        ) {
17919            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
17920            None => Ok(false),
17921        },
17922        TriggerCondition::IsNull { column_id } => Ok(matches!(
17923            selected.columns.get(column_id),
17924            None | Some(Value::Null)
17925        )),
17926        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
17927            selected.columns.get(column_id),
17928            None | Some(Value::Null)
17929        )),
17930        TriggerCondition::And { left, right } => {
17931            if !eval_trigger_condition(left, event, selected, schema)? {
17932                Ok(false)
17933            } else {
17934                Ok(eval_trigger_condition(right, event, selected, schema)?)
17935            }
17936        }
17937        TriggerCondition::Or { left, right } => {
17938            if eval_trigger_condition(left, event, selected, schema)? {
17939                Ok(true)
17940            } else {
17941                Ok(eval_trigger_condition(right, event, selected, schema)?)
17942            }
17943        }
17944        TriggerCondition::Not(condition) => {
17945            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
17946        }
17947    }
17948}
17949
17950fn eval_trigger_value(
17951    value: &TriggerValue,
17952    event: &WriteEvent,
17953    selected: Option<&TriggerRowImage>,
17954) -> Result<Value> {
17955    match value {
17956        TriggerValue::Literal(value) => Ok(value.clone()),
17957        TriggerValue::NewColumn(column_id) => event
17958            .new
17959            .as_ref()
17960            .and_then(|row| row.columns.get(column_id))
17961            .cloned()
17962            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
17963        TriggerValue::OldColumn(column_id) => event
17964            .old
17965            .as_ref()
17966            .and_then(|row| row.columns.get(column_id))
17967            .cloned()
17968            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
17969        TriggerValue::SelectedColumn(column_id) => selected
17970            .and_then(|row| row.columns.get(column_id))
17971            .cloned()
17972            .ok_or_else(|| {
17973                MongrelError::InvalidArgument("SELECTED column is not available".into())
17974            }),
17975    }
17976}
17977
17978fn values_equal(left: &Value, right: &Value) -> bool {
17979    match (left, right) {
17980        (Value::Null, Value::Null) => true,
17981        (Value::Bool(a), Value::Bool(b)) => a == b,
17982        (Value::Int64(a), Value::Int64(b)) => a == b,
17983        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
17984        (Value::Bytes(a), Value::Bytes(b)) => a == b,
17985        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => {
17986            let a = a.as_embedding().unwrap();
17987            let b = b.as_embedding().unwrap();
17988            a.len() == b.len()
17989                && a.iter()
17990                    .zip(b.iter())
17991                    .all(|(a, b)| a.to_bits() == b.to_bits())
17992        }
17993        _ => false,
17994    }
17995}
17996
17997fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
17998    match (left, right) {
17999        (Value::Null, _) | (_, Value::Null) => None,
18000        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
18001        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
18002        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18003        // This matches the spec but can lose precision for i64 values above 2^53.
18004        (Value::Int64(a), Value::Float64(b)) => {
18005            let af = *a as f64;
18006            Some(af.total_cmp(b))
18007        }
18008        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18009        // This matches the spec but can lose precision for i64 values above 2^53.
18010        (Value::Float64(a), Value::Int64(b)) => {
18011            let bf = *b as f64;
18012            Some(a.total_cmp(&bf))
18013        }
18014        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
18015        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
18016        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => None,
18017        _ => None,
18018    }
18019}
18020
18021fn trigger_message(value: Value) -> String {
18022    match value {
18023        Value::Null => "NULL".into(),
18024        Value::Bool(value) => value.to_string(),
18025        Value::Int64(value) => value.to_string(),
18026        Value::Float64(value) => value.to_string(),
18027        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
18028        Value::Embedding(value) => format!("{value:?}"),
18029        Value::GeneratedEmbedding(value) => format!("{:?}", value.vector),
18030        Value::Decimal(value) => value.to_string(),
18031        Value::Interval {
18032            months,
18033            days,
18034            nanos,
18035        } => format!("{months}m {days}d {nanos}ns"),
18036        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
18037        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
18038    }
18039}
18040
18041fn validate_trigger_step<'a>(
18042    step: &TriggerStep,
18043    cat: &'a Catalog,
18044    target_schema: &Schema,
18045    event: TriggerEvent,
18046    select_schemas: &mut HashMap<String, &'a Schema>,
18047) -> Result<()> {
18048    match step {
18049        TriggerStep::SetNew { cells } => {
18050            if event == TriggerEvent::Delete {
18051                return Err(MongrelError::InvalidArgument(
18052                    "SetNew trigger step is not valid for DELETE triggers".into(),
18053                ));
18054            }
18055            for cell in cells {
18056                validate_column_id(cell.column_id, target_schema)?;
18057                validate_trigger_value(&cell.value, target_schema, event)?;
18058            }
18059        }
18060        TriggerStep::Insert { table, cells } => {
18061            let schema = trigger_write_schema(cat, table, "insert")?;
18062            for cell in cells {
18063                validate_column_id(cell.column_id, schema)?;
18064                validate_trigger_value(&cell.value, target_schema, event)?;
18065            }
18066        }
18067        TriggerStep::UpdateByPk { table, pk, cells } => {
18068            let schema = trigger_write_schema(cat, table, "update")?;
18069            if schema.primary_key().is_none() {
18070                return Err(MongrelError::InvalidArgument(format!(
18071                    "trigger update_by_pk references table {table:?} without a primary key"
18072                )));
18073            }
18074            validate_trigger_value(pk, target_schema, event)?;
18075            for cell in cells {
18076                validate_column_id(cell.column_id, schema)?;
18077                validate_trigger_value(&cell.value, target_schema, event)?;
18078            }
18079        }
18080        TriggerStep::DeleteByPk { table, pk } => {
18081            let schema = trigger_write_schema(cat, table, "delete")?;
18082            if schema.primary_key().is_none() {
18083                return Err(MongrelError::InvalidArgument(format!(
18084                    "trigger delete_by_pk references table {table:?} without a primary key"
18085                )));
18086            }
18087            validate_trigger_value(pk, target_schema, event)?;
18088        }
18089        TriggerStep::Select {
18090            id,
18091            table,
18092            conditions,
18093        } => {
18094            let schema = trigger_read_schema(cat, table)?;
18095            for condition in conditions {
18096                validate_trigger_condition(condition, schema, target_schema, event)?;
18097            }
18098            if select_schemas.contains_key(id) {
18099                return Err(MongrelError::InvalidArgument(format!(
18100                    "duplicate select id {id:?} in trigger program"
18101                )));
18102            }
18103            select_schemas.insert(id.clone(), schema);
18104        }
18105        TriggerStep::Foreach { id, steps } => {
18106            if !select_schemas.contains_key(id) {
18107                return Err(MongrelError::InvalidArgument(format!(
18108                    "foreach references unknown select id {id:?}"
18109                )));
18110            }
18111            let mut inner_select_schemas = select_schemas.clone();
18112            for step in steps {
18113                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
18114            }
18115        }
18116        TriggerStep::DeleteWhere { table, conditions } => {
18117            let schema = trigger_write_schema(cat, table, "delete")?;
18118            for condition in conditions {
18119                validate_trigger_condition(condition, schema, target_schema, event)?;
18120            }
18121        }
18122        TriggerStep::UpdateWhere {
18123            table,
18124            conditions,
18125            cells,
18126        } => {
18127            let schema = trigger_write_schema(cat, table, "update")?;
18128            for condition in conditions {
18129                validate_trigger_condition(condition, schema, target_schema, event)?;
18130            }
18131            for cell in cells {
18132                validate_column_id(cell.column_id, schema)?;
18133                validate_trigger_value(&cell.value, target_schema, event)?;
18134            }
18135        }
18136        TriggerStep::Raise { message, .. } => {
18137            validate_trigger_value(message, target_schema, event)?
18138        }
18139    }
18140    Ok(())
18141}
18142
18143fn trigger_validation_error(error: MongrelError) -> MongrelError {
18144    match error {
18145        MongrelError::TriggerValidation(_) => error,
18146        MongrelError::InvalidArgument(message)
18147        | MongrelError::Conflict(message)
18148        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
18149        error => error,
18150    }
18151}
18152
18153fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
18154    if let Some(entry) = cat.live(table) {
18155        return Ok(&entry.schema);
18156    }
18157    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18158        let allowed = match op {
18159            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
18160            "update" | "delete" => entry.capabilities.writable,
18161            _ => false,
18162        };
18163        if !allowed {
18164            return Err(MongrelError::InvalidArgument(format!(
18165                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
18166                entry.module
18167            )));
18168        }
18169        if !entry.capabilities.transaction_safe {
18170            return Err(MongrelError::InvalidArgument(format!(
18171                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
18172                entry.module
18173            )));
18174        }
18175        return Ok(&entry.declared_schema);
18176    }
18177    Err(MongrelError::InvalidArgument(format!(
18178        "trigger references unknown table {table:?}"
18179    )))
18180}
18181
18182fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
18183    if let Some(entry) = cat.live(table) {
18184        return Ok(&entry.schema);
18185    }
18186    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18187        if entry.capabilities.trigger_safe {
18188            return Ok(&entry.declared_schema);
18189        }
18190        return Err(MongrelError::InvalidArgument(format!(
18191            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
18192            entry.module
18193        )));
18194    }
18195    Err(MongrelError::InvalidArgument(format!(
18196        "trigger references unknown table {table:?}"
18197    )))
18198}
18199
18200fn validate_trigger_condition(
18201    condition: &TriggerCondition,
18202    schema: &Schema,
18203    target_schema: &Schema,
18204    event: TriggerEvent,
18205) -> Result<()> {
18206    match condition {
18207        TriggerCondition::Pk { value } => {
18208            if schema.primary_key().is_none() {
18209                return Err(MongrelError::InvalidArgument(
18210                    "trigger condition Pk references a table without a primary key".into(),
18211                ));
18212            }
18213            validate_trigger_value(value, target_schema, event)
18214        }
18215        TriggerCondition::Eq { column_id, value }
18216        | TriggerCondition::NotEq { column_id, value }
18217        | TriggerCondition::Lt { column_id, value }
18218        | TriggerCondition::Lte { column_id, value }
18219        | TriggerCondition::Gt { column_id, value }
18220        | TriggerCondition::Gte { column_id, value } => {
18221            validate_column_id(*column_id, schema)?;
18222            validate_trigger_value(value, target_schema, event)
18223        }
18224        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
18225            validate_column_id(*column_id, schema)
18226        }
18227        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
18228            validate_trigger_condition(left, schema, target_schema, event)?;
18229            validate_trigger_condition(right, schema, target_schema, event)
18230        }
18231        TriggerCondition::Not(condition) => {
18232            validate_trigger_condition(condition, schema, target_schema, event)
18233        }
18234    }
18235}
18236
18237fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
18238    match expr {
18239        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
18240            validate_trigger_value(value, schema, event)
18241        }
18242        TriggerExpr::Eq { left, right }
18243        | TriggerExpr::NotEq { left, right }
18244        | TriggerExpr::Lt { left, right }
18245        | TriggerExpr::Lte { left, right }
18246        | TriggerExpr::Gt { left, right }
18247        | TriggerExpr::Gte { left, right } => {
18248            validate_trigger_value(left, schema, event)?;
18249            validate_trigger_value(right, schema, event)
18250        }
18251        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
18252            validate_trigger_expr(left, schema, event)?;
18253            validate_trigger_expr(right, schema, event)
18254        }
18255        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
18256    }
18257}
18258
18259fn validate_trigger_value(
18260    value: &TriggerValue,
18261    schema: &Schema,
18262    event: TriggerEvent,
18263) -> Result<()> {
18264    match value {
18265        TriggerValue::Literal(_) => Ok(()),
18266        TriggerValue::NewColumn(id) => {
18267            if event == TriggerEvent::Delete {
18268                return Err(MongrelError::InvalidArgument(
18269                    "DELETE triggers cannot reference NEW".into(),
18270                ));
18271            }
18272            validate_column_id(*id, schema)
18273        }
18274        TriggerValue::OldColumn(id) => {
18275            if event == TriggerEvent::Insert {
18276                return Err(MongrelError::InvalidArgument(
18277                    "INSERT triggers cannot reference OLD".into(),
18278                ));
18279            }
18280            validate_column_id(*id, schema)
18281        }
18282        // SELECTED column references are only meaningful inside a foreach loop.
18283        // Strict loop-scope validation is deferred to runtime; the executor raises
18284        // an error if a selected row is not available.
18285        TriggerValue::SelectedColumn(_) => Ok(()),
18286    }
18287}
18288
18289/// Bound on the retained tail of the per-open commit-timestamp ledger
18290/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
18291/// needs recent epochs, so the map keeps the newest commits and drops the
18292/// rest (a miss falls back to a fresh-begin HLC at the caller).
18293const COMMIT_TS_LEDGER_CAP: usize = 10_000;
18294
18295/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
18296/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
18297/// and the `Op::CommitTimestamp` record written ahead of it carries the
18298/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
18299/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
18300/// micros only (same constraint [`crate::commit_log`] documents for replayed
18301/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
18302fn commit_ts_ledger_from_recovery(
18303    records: &[crate::wal::Record],
18304) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
18305    use crate::wal::Op;
18306    let mut commits = HashMap::new();
18307    let mut timestamps = HashMap::new();
18308    for record in records {
18309        match &record.op {
18310            Op::TxnCommit { epoch, .. } => {
18311                commits.insert(record.txn_id, *epoch);
18312            }
18313            Op::CommitTimestamp { unix_nanos } => {
18314                timestamps.insert(record.txn_id, *unix_nanos);
18315            }
18316            _ => {}
18317        }
18318    }
18319    let mut ledger = std::collections::BTreeMap::new();
18320    for (txn_id, epoch) in commits {
18321        let Some(unix_nanos) = timestamps.get(&txn_id) else {
18322            continue;
18323        };
18324        ledger.insert(
18325            epoch,
18326            mongreldb_types::hlc::HlcTimestamp {
18327                physical_micros: unix_nanos / 1_000,
18328                logical: 0,
18329                node_tiebreaker: 0,
18330            },
18331        );
18332    }
18333    while ledger.len() > COMMIT_TS_LEDGER_CAP {
18334        ledger.pop_first();
18335    }
18336    ledger
18337}
18338
18339/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
18340/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
18341/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
18342/// catalog. This pass closes that window by reconstructing missing entries
18343/// (and marking committed drops) before tables are mounted.
18344fn recover_ddl_from_wal(
18345    root: &Path,
18346    durable_root: Option<&crate::durable_file::DurableRoot>,
18347    target_catalog: &mut Catalog,
18348    meta_dek: Option<&[u8; META_DEK_LEN]>,
18349    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
18350    apply: bool,
18351    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18352) -> Result<()> {
18353    use crate::wal::SharedWal;
18354    let records = match durable_root {
18355        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
18356        None => SharedWal::replay_with_dek(root, wal_dek)?,
18357    };
18358    recover_ddl_from_records(
18359        root,
18360        durable_root,
18361        target_catalog,
18362        meta_dek,
18363        apply,
18364        table_roots,
18365        &records,
18366    )
18367}
18368
18369fn recover_ddl_from_records(
18370    root: &Path,
18371    durable_root: Option<&crate::durable_file::DurableRoot>,
18372    target_catalog: &mut Catalog,
18373    meta_dek: Option<&[u8; META_DEK_LEN]>,
18374    apply: bool,
18375    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18376    records: &[crate::wal::Record],
18377) -> Result<()> {
18378    use crate::wal::{DdlOp, Op};
18379
18380    let original_catalog = target_catalog.clone();
18381    let mut recovered_catalog = original_catalog.clone();
18382    let cat = &mut recovered_catalog;
18383    let mut created_table_ids = HashSet::<u64>::new();
18384    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
18385
18386    let mut committed: HashMap<u64, u64> = HashMap::new();
18387    for r in records {
18388        if let Op::TxnCommit { epoch: ce, .. } = r.op {
18389            committed.insert(r.txn_id, ce);
18390        }
18391    }
18392    let catalog_snapshot_txns = records
18393        .iter()
18394        .filter_map(|record| {
18395            (committed.contains_key(&record.txn_id)
18396                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
18397            .then_some(record.txn_id)
18398        })
18399        .collect::<HashSet<_>>();
18400
18401    let mut changed = false;
18402    let mut applied_catalog_epoch = cat.db_epoch;
18403    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
18404    for r in records.iter().cloned() {
18405        let Some(&ce) = committed.get(&r.txn_id) else {
18406            continue;
18407        };
18408        let txn_id = r.txn_id;
18409        match r.op {
18410            Op::Ddl(DdlOp::CreateTable {
18411                table_id,
18412                ref name,
18413                ref schema_json,
18414            }) => {
18415                if cat.tables.iter().any(|t| t.table_id == table_id) {
18416                    continue;
18417                }
18418                let schema = DdlOp::decode_schema(schema_json)?;
18419                validate_recovered_schema(&schema)?;
18420                created_table_ids.insert(table_id);
18421                cat.tables.push(CatalogEntry {
18422                    table_id,
18423                    name: name.clone(),
18424                    schema,
18425                    state: TableState::Live,
18426                    created_epoch: ce,
18427                });
18428                cat.next_table_id =
18429                    cat.next_table_id
18430                        .max(table_id.checked_add(1).ok_or_else(|| {
18431                            MongrelError::Full("table id namespace exhausted".into())
18432                        })?);
18433                changed = true;
18434            }
18435            Op::Ddl(DdlOp::CreateBuildingTable {
18436                table_id,
18437                ref build_name,
18438                ref intended_name,
18439                ref query_id,
18440                created_at_unix_nanos,
18441                ref schema_json,
18442            }) => {
18443                if cat.tables.iter().any(|table| table.table_id == table_id) {
18444                    continue;
18445                }
18446                let schema = DdlOp::decode_schema(schema_json)?;
18447                validate_recovered_schema(&schema)?;
18448                created_table_ids.insert(table_id);
18449                cat.tables.push(CatalogEntry {
18450                    table_id,
18451                    name: build_name.clone(),
18452                    schema,
18453                    state: TableState::Building {
18454                        intended_name: intended_name.clone(),
18455                        query_id: query_id.clone(),
18456                        created_at_unix_nanos,
18457                        replaces_table_id: None,
18458                    },
18459                    created_epoch: ce,
18460                });
18461                cat.next_table_id =
18462                    cat.next_table_id
18463                        .max(table_id.checked_add(1).ok_or_else(|| {
18464                            MongrelError::Full("table id namespace exhausted".into())
18465                        })?);
18466                changed = true;
18467            }
18468            Op::Ddl(DdlOp::CreateRebuildingTable {
18469                table_id,
18470                ref build_name,
18471                ref intended_name,
18472                ref query_id,
18473                created_at_unix_nanos,
18474                replaces_table_id,
18475                ref schema_json,
18476            }) => {
18477                if cat.tables.iter().any(|table| table.table_id == table_id) {
18478                    continue;
18479                }
18480                let schema = DdlOp::decode_schema(schema_json)?;
18481                validate_recovered_schema(&schema)?;
18482                created_table_ids.insert(table_id);
18483                cat.tables.push(CatalogEntry {
18484                    table_id,
18485                    name: build_name.clone(),
18486                    schema,
18487                    state: TableState::Building {
18488                        intended_name: intended_name.clone(),
18489                        query_id: query_id.clone(),
18490                        created_at_unix_nanos,
18491                        replaces_table_id: Some(replaces_table_id),
18492                    },
18493                    created_epoch: ce,
18494                });
18495                cat.next_table_id =
18496                    cat.next_table_id
18497                        .max(table_id.checked_add(1).ok_or_else(|| {
18498                            MongrelError::Full("table id namespace exhausted".into())
18499                        })?);
18500                changed = true;
18501            }
18502            Op::Ddl(DdlOp::DropTable { table_id }) => {
18503                let mut dropped_name = None;
18504                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18505                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18506                        dropped_name = Some(entry.name.clone());
18507                        entry.state = TableState::Dropped { at_epoch: ce };
18508                        changed = true;
18509                    }
18510                }
18511                if let Some(name) = dropped_name {
18512                    let before = cat.materialized_views.len();
18513                    cat.materialized_views
18514                        .retain(|definition| definition.name != name);
18515                    changed |= before != cat.materialized_views.len();
18516                    cat.security.rls_tables.retain(|table| table != &name);
18517                    cat.security.policies.retain(|policy| policy.table != name);
18518                    cat.security.masks.retain(|mask| mask.table != name);
18519                    for role in &mut cat.roles {
18520                        role.permissions
18521                            .retain(|permission| permission_table(permission) != Some(&name));
18522                    }
18523                    if !catalog_snapshot_txns.contains(&txn_id) {
18524                        advance_security_version(cat)?;
18525                    }
18526                }
18527            }
18528            Op::Ddl(DdlOp::PublishBuildingTable {
18529                table_id,
18530                ref new_name,
18531            }) => {
18532                if let Some(entry) = cat
18533                    .tables
18534                    .iter_mut()
18535                    .find(|table| table.table_id == table_id)
18536                {
18537                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
18538                        entry.name = new_name.clone();
18539                        entry.state = TableState::Live;
18540                        changed = true;
18541                    }
18542                }
18543            }
18544            Op::Ddl(DdlOp::ReplaceBuildingTable {
18545                table_id,
18546                replaced_table_id,
18547                ref new_name,
18548            }) => {
18549                changed |=
18550                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
18551            }
18552            Op::Ddl(DdlOp::RenameTable {
18553                table_id,
18554                ref new_name,
18555            }) => {
18556                let mut old_name = None;
18557                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18558                    if entry.name != *new_name {
18559                        old_name = Some(entry.name.clone());
18560                        entry.name = new_name.clone();
18561                        changed = true;
18562                    }
18563                }
18564                if let Some(old_name) = old_name {
18565                    if let Some(definition) = cat
18566                        .materialized_views
18567                        .iter_mut()
18568                        .find(|definition| definition.name == old_name)
18569                    {
18570                        definition.name = new_name.clone();
18571                    }
18572                    for table in &mut cat.security.rls_tables {
18573                        if *table == old_name {
18574                            *table = new_name.clone();
18575                        }
18576                    }
18577                    for policy in &mut cat.security.policies {
18578                        if policy.table == old_name {
18579                            policy.table = new_name.clone();
18580                        }
18581                    }
18582                    for mask in &mut cat.security.masks {
18583                        if mask.table == old_name {
18584                            mask.table = new_name.clone();
18585                        }
18586                    }
18587                    for role in &mut cat.roles {
18588                        for permission in &mut role.permissions {
18589                            rename_permission_table(permission, &old_name, new_name);
18590                        }
18591                    }
18592                    if !catalog_snapshot_txns.contains(&txn_id) {
18593                        advance_security_version(cat)?;
18594                    }
18595                }
18596                // If the entry is absent, its CreateTable was already
18597                // checkpointed carrying the post-rename name, so there is
18598                // nothing to apply — a no-op, not an error.
18599            }
18600            Op::Ddl(DdlOp::AlterTable {
18601                table_id,
18602                ref column_json,
18603            }) => {
18604                let column = DdlOp::decode_column(column_json)?;
18605                let mut renamed = None;
18606                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18607                    renamed = entry
18608                        .schema
18609                        .columns
18610                        .iter()
18611                        .find(|existing| existing.id == column.id && existing.name != column.name)
18612                        .map(|existing| {
18613                            (
18614                                entry.name.clone(),
18615                                existing.name.clone(),
18616                                column.name.clone(),
18617                            )
18618                        });
18619                    if apply_recovered_column_def(&mut entry.schema, column)? {
18620                        validate_recovered_schema(&entry.schema)?;
18621                        changed = true;
18622                    }
18623                }
18624                if let Some((table, old_name, new_name)) = renamed {
18625                    for role in &mut cat.roles {
18626                        for permission in &mut role.permissions {
18627                            rename_permission_column(permission, &table, &old_name, &new_name);
18628                        }
18629                    }
18630                    if !catalog_snapshot_txns.contains(&txn_id) {
18631                        advance_security_version(cat)?;
18632                    }
18633                }
18634            }
18635            Op::Ddl(DdlOp::SetTtl {
18636                table_id,
18637                ref policy_json,
18638            }) => {
18639                let policy = DdlOp::decode_ttl(policy_json)?;
18640                let entry = cat
18641                    .tables
18642                    .iter()
18643                    .find(|entry| entry.table_id == table_id)
18644                    .ok_or_else(|| {
18645                        MongrelError::Schema(format!(
18646                            "recovered TTL references unknown table id {table_id}"
18647                        ))
18648                    })?;
18649                if let Some(policy) = policy {
18650                    let valid = entry
18651                        .schema
18652                        .columns
18653                        .iter()
18654                        .find(|column| column.id == policy.column_id)
18655                        .is_some_and(|column| {
18656                            column.ty == TypeId::TimestampNanos
18657                                && policy.duration_nanos > 0
18658                                && policy.duration_nanos <= i64::MAX as u64
18659                        });
18660                    if !valid {
18661                        return Err(MongrelError::Schema(format!(
18662                            "invalid recovered TTL policy for table id {table_id}"
18663                        )));
18664                    }
18665                }
18666                ttl_updates.insert(table_id, (policy, ce));
18667            }
18668            Op::Ddl(DdlOp::SetMaterializedView {
18669                ref name,
18670                ref definition_json,
18671            }) => {
18672                let definition = DdlOp::decode_materialized_view(definition_json)?;
18673                if definition.name != *name {
18674                    return Err(MongrelError::Schema(format!(
18675                        "materialized view WAL name mismatch: {name:?}"
18676                    )));
18677                }
18678                if cat.live(name).is_some() {
18679                    if let Some(existing) = cat
18680                        .materialized_views
18681                        .iter_mut()
18682                        .find(|existing| existing.name == *name)
18683                    {
18684                        if *existing != definition {
18685                            *existing = definition;
18686                            changed = true;
18687                        }
18688                    } else {
18689                        cat.materialized_views.push(definition);
18690                        changed = true;
18691                    }
18692                }
18693            }
18694            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
18695                let security = DdlOp::decode_security(security_json)?;
18696                validate_security_catalog(cat, &security)?;
18697                if cat.security != security {
18698                    cat.security = security;
18699                    if !catalog_snapshot_txns.contains(&txn_id) {
18700                        advance_security_version(cat)?;
18701                    }
18702                    changed = true;
18703                }
18704            }
18705            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
18706                let target = match key.as_str() {
18707                    "user_version" => &mut cat.user_version,
18708                    "application_id" => &mut cat.application_id,
18709                    _ => {
18710                        return Err(MongrelError::InvalidArgument(format!(
18711                            "unsupported recovered SQL pragma {key:?}"
18712                        )))
18713                    }
18714                };
18715                if *target != Some(value) {
18716                    *target = Some(value);
18717                    cat.db_epoch = cat.db_epoch.max(ce);
18718                    changed = true;
18719                }
18720            }
18721            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
18722                if ce <= applied_catalog_epoch {
18723                    continue;
18724                }
18725                let snapshot = DdlOp::decode_catalog(catalog_json)?;
18726                if snapshot.db_epoch != ce {
18727                    return Err(MongrelError::Schema(format!(
18728                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
18729                        snapshot.db_epoch
18730                    )));
18731                }
18732                validate_recovered_catalog(&snapshot)?;
18733                validate_catalog_transition(cat, &snapshot)?;
18734                *cat = snapshot;
18735                applied_catalog_epoch = ce;
18736                changed = true;
18737            }
18738            _ => {}
18739        }
18740    }
18741
18742    if cat.db_epoch < max_committed_epoch {
18743        cat.db_epoch = max_committed_epoch;
18744        changed = true;
18745    }
18746    changed |= repair_catalog_allocator_counters(cat)?;
18747
18748    validate_recovered_catalog(cat)?;
18749    let storage_reconciliation = validate_recovered_storage_plan(
18750        root,
18751        durable_root,
18752        cat,
18753        &created_table_ids,
18754        &ttl_updates,
18755        meta_dek,
18756    )?;
18757
18758    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
18759    if apply && (changed || needs_storage_apply) {
18760        for table_id in storage_reconciliation {
18761            let entry = cat
18762                .tables
18763                .iter()
18764                .find(|entry| entry.table_id == table_id)
18765                .ok_or_else(|| MongrelError::CorruptWal {
18766                    offset: table_id,
18767                    reason: "recovery storage plan lost its catalog table".into(),
18768                })?;
18769            ensure_recovered_table_storage(
18770                table_roots
18771                    .and_then(|roots| roots.get(&table_id))
18772                    .map(Arc::as_ref),
18773                durable_root,
18774                &root.join(TABLES_DIR).join(table_id.to_string()),
18775                table_id,
18776                &entry.schema,
18777                meta_dek,
18778            )?;
18779        }
18780        for (table_id, (policy, ttl_epoch)) in ttl_updates {
18781            let Some(entry) = cat.tables.iter().find(|entry| {
18782                entry.table_id == table_id
18783                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
18784            }) else {
18785                continue;
18786            };
18787            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
18788            {
18789                root.try_clone()?
18790            } else if let Some(root) = durable_root {
18791                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
18792            } else {
18793                crate::durable_file::DurableRoot::open(
18794                    root.join(TABLES_DIR).join(table_id.to_string()),
18795                )?
18796            };
18797            let table_dir = table_root.io_path()?;
18798            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
18799            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
18800                manifest.ttl = policy;
18801                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
18802                manifest.schema_id = entry.schema.schema_id;
18803                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18804            }
18805        }
18806        if changed {
18807            match durable_root {
18808                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
18809                None => catalog::write_atomic(root, cat, meta_dek)?,
18810            }
18811        }
18812    }
18813    *target_catalog = recovered_catalog;
18814    Ok(())
18815}
18816
18817fn ensure_recovered_table_storage(
18818    pinned_table: Option<&crate::durable_file::DurableRoot>,
18819    durable_root: Option<&crate::durable_file::DurableRoot>,
18820    fallback_table_dir: &Path,
18821    table_id: u64,
18822    schema: &Schema,
18823    meta_dek: Option<&[u8; META_DEK_LEN]>,
18824) -> Result<()> {
18825    let table_root = if let Some(root) = pinned_table {
18826        root.try_clone()?
18827    } else if let Some(root) = durable_root {
18828        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
18829        match root.open_directory(&relative) {
18830            Ok(table) => table,
18831            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
18832                root.create_directory_all_pinned(relative)?
18833            }
18834            Err(error) => return Err(error.into()),
18835        }
18836    } else {
18837        crate::durable_file::create_directory_all(fallback_table_dir)?;
18838        crate::durable_file::DurableRoot::open(fallback_table_dir)?
18839    };
18840    let table_dir = table_root.io_path()?;
18841    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
18842        Ok(manifest) => {
18843            if manifest.table_id != table_id {
18844                return Err(MongrelError::Conflict(format!(
18845                    "recovered table directory id mismatch: expected {table_id}, found {}",
18846                    manifest.table_id
18847                )));
18848            }
18849            Some(manifest)
18850        }
18851        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
18852        Err(error) => return Err(error),
18853    };
18854
18855    table_root.create_directory_all(crate::engine::WAL_DIR)?;
18856    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
18857    crate::engine::write_schema(&table_dir, schema)?;
18858
18859    if let Some(mut manifest) = existing_manifest.take() {
18860        if manifest.schema_id != schema.schema_id {
18861            manifest.schema_id = schema.schema_id;
18862            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18863        }
18864    } else {
18865        // The DB-wide meta DEK is also the per-table manifest meta DEK.
18866        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
18867        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18868    }
18869    Ok(())
18870}
18871
18872fn validate_recovered_schema(schema: &Schema) -> Result<()> {
18873    schema.validate_auto_increment()?;
18874    schema.validate_defaults()?;
18875    schema.validate_ai()?;
18876    let mut column_ids = HashSet::new();
18877    let mut column_names = HashSet::new();
18878    for column in &schema.columns {
18879        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
18880            return Err(MongrelError::Schema(
18881                "recovered schema contains duplicate columns".into(),
18882            ));
18883        }
18884        match &column.ty {
18885            TypeId::Decimal128 { precision, scale }
18886                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
18887            {
18888                return Err(MongrelError::Schema(format!(
18889                    "column {:?} has invalid decimal precision or scale",
18890                    column.name
18891                )));
18892            }
18893            TypeId::Enum { variants }
18894                if variants.is_empty()
18895                    || variants.iter().any(String::is_empty)
18896                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
18897            {
18898                return Err(MongrelError::Schema(format!(
18899                    "column {:?} has invalid enum variants",
18900                    column.name
18901                )));
18902            }
18903            _ => {}
18904        }
18905    }
18906    let mut index_names = HashSet::new();
18907    for index in &schema.indexes {
18908        index.validate_options()?;
18909        if index.name.is_empty()
18910            || !index_names.insert(index.name.as_str())
18911            || schema
18912                .columns
18913                .iter()
18914                .all(|column| column.id != index.column_id)
18915        {
18916            return Err(MongrelError::Schema(format!(
18917                "recovered index {:?} references missing column {}",
18918                index.name, index.column_id
18919            )));
18920        }
18921    }
18922    let mut colocated = HashSet::new();
18923    for group in &schema.colocation {
18924        if group.is_empty()
18925            || group.iter().any(|id| !column_ids.contains(id))
18926            || group.iter().any(|id| !colocated.insert(*id))
18927        {
18928            return Err(MongrelError::Schema(
18929                "recovered schema contains invalid column co-location groups".into(),
18930            ));
18931        }
18932    }
18933
18934    let mut constraint_ids = HashSet::new();
18935    let mut constraint_names = HashSet::<String>::new();
18936    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
18937        if name.is_empty()
18938            || !constraint_ids.insert(id)
18939            || !constraint_names.insert(name.to_owned())
18940        {
18941            return Err(MongrelError::Schema(
18942                "recovered schema contains duplicate or empty constraint identities".into(),
18943            ));
18944        }
18945        Ok(())
18946    };
18947    for unique in &schema.constraints.uniques {
18948        validate_constraint_identity(unique.id, &unique.name)?;
18949        if unique.columns.is_empty()
18950            || unique.columns.iter().any(|id| !column_ids.contains(id))
18951            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
18952        {
18953            return Err(MongrelError::Schema(format!(
18954                "unique constraint {:?} has invalid columns",
18955                unique.name
18956            )));
18957        }
18958    }
18959    for foreign_key in &schema.constraints.foreign_keys {
18960        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
18961        if foreign_key.ref_table.is_empty()
18962            || foreign_key.columns.is_empty()
18963            || foreign_key.columns.len() != foreign_key.ref_columns.len()
18964            || foreign_key
18965                .columns
18966                .iter()
18967                .any(|id| !column_ids.contains(id))
18968            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
18969            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
18970                != foreign_key.ref_columns.len()
18971        {
18972            return Err(MongrelError::Schema(format!(
18973                "foreign key {:?} has invalid columns",
18974                foreign_key.name
18975            )));
18976        }
18977        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
18978            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
18979            && foreign_key.columns.iter().any(|id| {
18980                schema
18981                    .columns
18982                    .iter()
18983                    .find(|column| column.id == *id)
18984                    .is_none_or(|column| {
18985                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
18986                    })
18987            })
18988        {
18989            return Err(MongrelError::Schema(format!(
18990                "foreign key {:?} uses SET NULL on a non-nullable column",
18991                foreign_key.name
18992            )));
18993        }
18994    }
18995    for check in &schema.constraints.checks {
18996        validate_constraint_identity(check.id, &check.name)?;
18997        check.expr.validate()?;
18998        validate_check_columns(&check.expr, &column_ids)?;
18999    }
19000    Ok(())
19001}
19002
19003fn validate_check_columns(
19004    expression: &crate::constraint::CheckExpr,
19005    column_ids: &HashSet<u16>,
19006) -> Result<()> {
19007    use crate::constraint::CheckExpr;
19008    match expression {
19009        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
19010            if column_ids.contains(id) {
19011                Ok(())
19012            } else {
19013                Err(MongrelError::Schema(format!(
19014                    "check constraint references unknown column {id}"
19015                )))
19016            }
19017        }
19018        CheckExpr::Regex { col, .. } => {
19019            if column_ids.contains(col) {
19020                Ok(())
19021            } else {
19022                Err(MongrelError::Schema(format!(
19023                    "check constraint references unknown column {col}"
19024                )))
19025            }
19026        }
19027        CheckExpr::Add(left, right)
19028        | CheckExpr::Sub(left, right)
19029        | CheckExpr::Mul(left, right)
19030        | CheckExpr::Div(left, right)
19031        | CheckExpr::Mod(left, right)
19032        | CheckExpr::Eq(left, right)
19033        | CheckExpr::Ne(left, right)
19034        | CheckExpr::Lt(left, right)
19035        | CheckExpr::Le(left, right)
19036        | CheckExpr::Gt(left, right)
19037        | CheckExpr::Ge(left, right)
19038        | CheckExpr::And(left, right)
19039        | CheckExpr::Or(left, right) => {
19040            validate_check_columns(left, column_ids)?;
19041            validate_check_columns(right, column_ids)
19042        }
19043        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
19044        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
19045    }
19046}
19047
19048fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
19049    for (name, prior, candidate) in [
19050        ("db_epoch", current.db_epoch, next.db_epoch),
19051        ("next_table_id", current.next_table_id, next.next_table_id),
19052        (
19053            "next_segment_no",
19054            current.next_segment_no,
19055            next.next_segment_no,
19056        ),
19057        ("next_user_id", current.next_user_id, next.next_user_id),
19058        (
19059            "security_version",
19060            current.security_version,
19061            next.security_version,
19062        ),
19063    ] {
19064        if candidate < prior {
19065            return Err(MongrelError::Schema(format!(
19066                "catalog snapshot rolls back {name} from {prior} to {candidate}"
19067            )));
19068        }
19069    }
19070    for prior in &current.tables {
19071        let Some(candidate) = next
19072            .tables
19073            .iter()
19074            .find(|entry| entry.table_id == prior.table_id)
19075        else {
19076            return Err(MongrelError::Schema(format!(
19077                "catalog snapshot removes table identity {}",
19078                prior.table_id
19079            )));
19080        };
19081        if candidate.created_epoch != prior.created_epoch
19082            || candidate.schema.schema_id < prior.schema.schema_id
19083            || matches!(prior.state, TableState::Dropped { .. })
19084                && !matches!(candidate.state, TableState::Dropped { .. })
19085        {
19086            return Err(MongrelError::Schema(format!(
19087                "catalog snapshot rolls back table identity {}",
19088                prior.table_id
19089            )));
19090        }
19091    }
19092    for prior in &current.users {
19093        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
19094            if candidate.username != prior.username
19095                || candidate.created_epoch != prior.created_epoch
19096            {
19097                return Err(MongrelError::Schema(format!(
19098                    "catalog snapshot reuses user identity {}",
19099                    prior.id
19100                )));
19101            }
19102        }
19103    }
19104    Ok(())
19105}
19106
19107fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
19108    let mut table_ids = HashSet::new();
19109    let mut active_names = HashSet::new();
19110    let mut max_table_id = None::<u64>;
19111    for entry in &catalog.tables {
19112        if !table_ids.insert(entry.table_id) {
19113            return Err(MongrelError::Schema(format!(
19114                "catalog contains duplicate table id {}",
19115                entry.table_id
19116            )));
19117        }
19118        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
19119        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
19120            return Err(MongrelError::Schema(format!(
19121                "catalog table {} has invalid name or creation epoch",
19122                entry.table_id
19123            )));
19124        }
19125        validate_recovered_schema(&entry.schema)?;
19126        match &entry.state {
19127            TableState::Live => {
19128                if !active_names.insert(entry.name.as_str()) {
19129                    return Err(MongrelError::Schema(format!(
19130                        "catalog contains duplicate active table name {:?}",
19131                        entry.name
19132                    )));
19133                }
19134            }
19135            TableState::Dropped { at_epoch } => {
19136                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
19137                    return Err(MongrelError::Schema(format!(
19138                        "catalog table {} has invalid drop epoch {at_epoch}",
19139                        entry.table_id
19140                    )));
19141                }
19142            }
19143            TableState::Building {
19144                intended_name,
19145                query_id,
19146                replaces_table_id,
19147                ..
19148            } => {
19149                if intended_name.is_empty() || query_id.is_empty() {
19150                    return Err(MongrelError::Schema(format!(
19151                        "building table {} has empty identity fields",
19152                        entry.table_id
19153                    )));
19154                }
19155                if !active_names.insert(entry.name.as_str()) {
19156                    return Err(MongrelError::Schema(format!(
19157                        "catalog contains duplicate active/building table name {:?}",
19158                        entry.name
19159                    )));
19160                }
19161                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
19162                    return Err(MongrelError::Schema(
19163                        "building table cannot replace itself".into(),
19164                    ));
19165                }
19166            }
19167        }
19168    }
19169    if let Some(maximum) = max_table_id {
19170        let required = maximum
19171            .checked_add(1)
19172            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19173        if catalog.next_table_id < required {
19174            return Err(MongrelError::Schema(format!(
19175                "catalog next_table_id {} precedes required {required}",
19176                catalog.next_table_id
19177            )));
19178        }
19179    }
19180    for entry in &catalog.tables {
19181        if let TableState::Building {
19182            replaces_table_id: Some(replaced),
19183            ..
19184        } = entry.state
19185        {
19186            if !table_ids.contains(&replaced) {
19187                return Err(MongrelError::Schema(format!(
19188                    "building table {} replaces unknown table {replaced}",
19189                    entry.table_id
19190                )));
19191            }
19192        }
19193    }
19194    for entry in &catalog.tables {
19195        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19196            validate_foreign_key_targets(catalog, &entry.schema)?;
19197        }
19198    }
19199
19200    let mut external_names = HashSet::new();
19201    for entry in &catalog.external_tables {
19202        entry.validate()?;
19203        validate_recovered_schema(&entry.declared_schema)?;
19204        if !entry.declared_schema.constraints.is_empty() {
19205            return Err(MongrelError::Schema(format!(
19206                "external table {:?} cannot carry engine-enforced constraints",
19207                entry.name
19208            )));
19209        }
19210        if entry.created_epoch > catalog.db_epoch
19211            || !external_names.insert(entry.name.as_str())
19212            || active_names.contains(entry.name.as_str())
19213        {
19214            return Err(MongrelError::Schema(format!(
19215                "invalid or duplicate external table {:?}",
19216                entry.name
19217            )));
19218        }
19219    }
19220
19221    let mut procedure_names = HashSet::new();
19222    for entry in &catalog.procedures {
19223        entry.procedure.validate()?;
19224        if entry.procedure.created_epoch > entry.procedure.updated_epoch
19225            || entry.procedure.updated_epoch > catalog.db_epoch
19226            || !procedure_names.insert(entry.procedure.name.as_str())
19227        {
19228            return Err(MongrelError::Schema(format!(
19229                "invalid or duplicate procedure {:?}",
19230                entry.procedure.name
19231            )));
19232        }
19233        validate_recovered_procedure_references(catalog, &entry.procedure)?;
19234    }
19235
19236    let mut trigger_names = HashSet::new();
19237    for entry in &catalog.triggers {
19238        entry.trigger.validate()?;
19239        if entry.trigger.created_epoch > entry.trigger.updated_epoch
19240            || entry.trigger.updated_epoch > catalog.db_epoch
19241            || !trigger_names.insert(entry.trigger.name.as_str())
19242        {
19243            return Err(MongrelError::Schema(format!(
19244                "invalid or duplicate trigger {:?}",
19245                entry.trigger.name
19246            )));
19247        }
19248        validate_recovered_trigger_references(catalog, &entry.trigger)?;
19249    }
19250
19251    let mut views = HashSet::new();
19252    for view in &catalog.materialized_views {
19253        let target = catalog.live(&view.name).ok_or_else(|| {
19254            MongrelError::Schema(format!(
19255                "materialized view {:?} has no live table",
19256                view.name
19257            ))
19258        })?;
19259        if view.name.is_empty()
19260            || view.query.trim().is_empty()
19261            || view.last_refresh_epoch > catalog.db_epoch
19262            || !views.insert(view.name.as_str())
19263        {
19264            return Err(MongrelError::Schema(format!(
19265                "materialized view {:?} has no unique live table",
19266                view.name
19267            )));
19268        }
19269        if let Some(incremental) = &view.incremental {
19270            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
19271                MongrelError::Schema(format!(
19272                    "materialized view {:?} references missing source {:?}",
19273                    view.name, incremental.source_table
19274                ))
19275            })?;
19276            if source.table_id != incremental.source_table_id
19277                || source
19278                    .schema
19279                    .columns
19280                    .iter()
19281                    .all(|column| column.id != incremental.group_column)
19282            {
19283                return Err(MongrelError::Schema(format!(
19284                    "materialized view {:?} has invalid incremental source",
19285                    view.name
19286                )));
19287            }
19288            let target_ids = target
19289                .schema
19290                .columns
19291                .iter()
19292                .map(|column| column.id)
19293                .collect::<HashSet<_>>();
19294            let mut output_ids = HashSet::new();
19295            let count_outputs = incremental
19296                .outputs
19297                .iter()
19298                .filter(|output| {
19299                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19300                })
19301                .count();
19302            if incremental.checkpoint_event_id.is_empty()
19303                || !target_ids.contains(&incremental.group_output_column)
19304                || !target_ids.contains(&incremental.count_output_column)
19305                || incremental.outputs.is_empty()
19306                || count_outputs != 1
19307                || incremental.outputs.iter().any(|output| {
19308                    !target_ids.contains(&output.output_column)
19309                        || output.output_column == incremental.group_output_column
19310                        || !output_ids.insert(output.output_column)
19311                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19312                            && output.output_column != incremental.count_output_column
19313                        || match output.kind {
19314                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
19315                                source
19316                                    .schema
19317                                    .columns
19318                                    .iter()
19319                                    .all(|column| column.id != source_column)
19320                            }
19321                            crate::catalog::IncrementalAggregateKind::Count => false,
19322                        }
19323                })
19324            {
19325                return Err(MongrelError::Schema(format!(
19326                    "materialized view {:?} has invalid incremental outputs",
19327                    view.name
19328                )));
19329            }
19330        }
19331    }
19332
19333    validate_security_catalog(catalog, &catalog.security)?;
19334    validate_recovered_auth_catalog(catalog)?;
19335    Ok(())
19336}
19337
19338fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
19339    let mut changed = false;
19340    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
19341        let required = maximum
19342            .checked_add(1)
19343            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19344        if catalog.next_table_id < required {
19345            catalog.next_table_id = required;
19346            changed = true;
19347        }
19348    }
19349    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
19350        let required = maximum
19351            .checked_add(1)
19352            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
19353        if catalog.next_user_id < required {
19354            catalog.next_user_id = required;
19355            changed = true;
19356        }
19357    }
19358    Ok(changed)
19359}
19360
19361fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
19362    for foreign_key in &schema.constraints.foreign_keys {
19363        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
19364            MongrelError::Schema(format!(
19365                "foreign key {:?} references unknown live table {:?}",
19366                foreign_key.name, foreign_key.ref_table
19367            ))
19368        })?;
19369        let referenced_unique = parent
19370            .schema
19371            .constraints
19372            .uniques
19373            .iter()
19374            .any(|unique| unique.columns == foreign_key.ref_columns)
19375            || foreign_key.ref_columns.len() == 1
19376                && parent
19377                    .schema
19378                    .primary_key()
19379                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
19380        if !referenced_unique {
19381            return Err(MongrelError::Schema(format!(
19382                "foreign key {:?} does not reference a unique key",
19383                foreign_key.name
19384            )));
19385        }
19386        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
19387            let local = schema.columns.iter().find(|column| column.id == *local_id);
19388            let referenced = parent
19389                .schema
19390                .columns
19391                .iter()
19392                .find(|column| column.id == *parent_id);
19393            if local
19394                .zip(referenced)
19395                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
19396            {
19397                return Err(MongrelError::Schema(format!(
19398                    "foreign key {:?} has missing or incompatible columns",
19399                    foreign_key.name
19400                )));
19401            }
19402        }
19403    }
19404    Ok(())
19405}
19406
19407fn validate_recovered_procedure_references(
19408    catalog: &Catalog,
19409    procedure: &StoredProcedure,
19410) -> Result<()> {
19411    for step in &procedure.body.steps {
19412        let Some(table_name) = step.table() else {
19413            continue;
19414        };
19415        let schema = &catalog
19416            .live(table_name)
19417            .ok_or_else(|| {
19418                MongrelError::Schema(format!(
19419                    "procedure {:?} references unknown table {table_name:?}",
19420                    procedure.name
19421                ))
19422            })?
19423            .schema;
19424        match step {
19425            ProcedureStep::NativeQuery {
19426                conditions,
19427                projection,
19428                ..
19429            } => {
19430                for condition in conditions {
19431                    validate_condition_columns(condition, schema)?;
19432                }
19433                for id in projection.iter().flatten() {
19434                    validate_column_id(*id, schema)?;
19435                }
19436            }
19437            ProcedureStep::Put { cells, .. } => {
19438                for cell in cells {
19439                    validate_column_id(cell.column_id, schema)?;
19440                }
19441            }
19442            ProcedureStep::Upsert {
19443                cells,
19444                update_cells,
19445                ..
19446            } => {
19447                for cell in cells.iter().chain(update_cells.iter().flatten()) {
19448                    validate_column_id(cell.column_id, schema)?;
19449                }
19450            }
19451            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
19452                return Err(MongrelError::Schema(format!(
19453                    "procedure {:?} deletes by primary key on table without one",
19454                    procedure.name
19455                )));
19456            }
19457            ProcedureStep::DeleteByPk { .. }
19458            | ProcedureStep::DeleteRows { .. }
19459            | ProcedureStep::SqlQuery { .. } => {}
19460        }
19461    }
19462    Ok(())
19463}
19464
19465fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
19466    let target_schema = match &trigger.target {
19467        TriggerTarget::Table(name) => catalog
19468            .live(name)
19469            .ok_or_else(|| {
19470                MongrelError::Schema(format!(
19471                    "trigger {:?} references unknown table {name:?}",
19472                    trigger.name
19473                ))
19474            })?
19475            .schema
19476            .clone(),
19477        TriggerTarget::View(_) => Schema {
19478            columns: trigger.target_columns.clone(),
19479            ..Schema::default()
19480        },
19481    };
19482    for column in &trigger.update_of {
19483        if target_schema.column(column).is_none() {
19484            return Err(MongrelError::Schema(format!(
19485                "trigger {:?} references unknown UPDATE OF column {column:?}",
19486                trigger.name
19487            )));
19488        }
19489    }
19490    if let Some(expr) = &trigger.when {
19491        validate_trigger_expr(expr, &target_schema, trigger.event)?;
19492    }
19493    let mut selects = HashMap::new();
19494    for step in &trigger.program.steps {
19495        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
19496            return Err(MongrelError::Schema(
19497                "SetNew is only valid in BEFORE triggers".into(),
19498            ));
19499        }
19500        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
19501    }
19502    Ok(())
19503}
19504
19505fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
19506    let mut role_names = HashSet::new();
19507    for role in &catalog.roles {
19508        if role.name.is_empty()
19509            || role.created_epoch > catalog.db_epoch
19510            || !role_names.insert(role.name.as_str())
19511        {
19512            return Err(MongrelError::Schema(format!(
19513                "invalid or duplicate role {:?}",
19514                role.name
19515            )));
19516        }
19517        for permission in &role.permissions {
19518            if let Some(table) = permission_table(permission) {
19519                let schema = catalog
19520                    .live(table)
19521                    .map(|entry| &entry.schema)
19522                    .or_else(|| {
19523                        catalog
19524                            .external_tables
19525                            .iter()
19526                            .find(|entry| entry.name == table)
19527                            .map(|entry| &entry.declared_schema)
19528                    })
19529                    .ok_or_else(|| {
19530                        MongrelError::Schema(format!(
19531                            "role {:?} references unknown table {table:?}",
19532                            role.name
19533                        ))
19534                    })?;
19535                let columns = match permission {
19536                    crate::auth::Permission::SelectColumns { columns, .. }
19537                    | crate::auth::Permission::InsertColumns { columns, .. }
19538                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
19539                    _ => None,
19540                };
19541                if columns.is_some_and(|columns| {
19542                    columns.is_empty()
19543                        || columns.iter().any(|column| schema.column(column).is_none())
19544                }) {
19545                    return Err(MongrelError::Schema(format!(
19546                        "role {:?} contains invalid column permissions",
19547                        role.name
19548                    )));
19549                }
19550            }
19551        }
19552    }
19553    let mut user_ids = HashSet::new();
19554    let mut usernames = HashSet::new();
19555    let mut maximum_user_id = 0;
19556    for user in &catalog.users {
19557        maximum_user_id = maximum_user_id.max(user.id);
19558        if user.id == 0
19559            || user.username.is_empty()
19560            || user.password_hash.is_empty()
19561            || user.created_epoch > catalog.db_epoch
19562            || !user_ids.insert(user.id)
19563            || !usernames.insert(user.username.as_str())
19564            || user
19565                .roles
19566                .iter()
19567                .any(|role| !role_names.contains(role.as_str()))
19568        {
19569            return Err(MongrelError::Schema(format!(
19570                "invalid or duplicate user {:?}",
19571                user.username
19572            )));
19573        }
19574    }
19575    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
19576        return Err(MongrelError::Schema(
19577            "catalog next_user_id does not advance beyond existing user ids".into(),
19578        ));
19579    }
19580    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
19581        return Err(MongrelError::Schema(
19582            "authenticated catalog has no administrator".into(),
19583        ));
19584    }
19585    Ok(())
19586}
19587
19588fn validate_recovered_storage_plan(
19589    root: &Path,
19590    durable_root: Option<&crate::durable_file::DurableRoot>,
19591    catalog: &Catalog,
19592    created_table_ids: &HashSet<u64>,
19593    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
19594    meta_dek: Option<&[u8; META_DEK_LEN]>,
19595) -> Result<Vec<u64>> {
19596    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
19597    let mut reconcile = Vec::new();
19598    for entry in &catalog.tables {
19599        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19600            continue;
19601        }
19602        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19603        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
19604        let table_exists = match durable_root {
19605            Some(root) => match root.open_directory(&relative_dir) {
19606                Ok(_) => true,
19607                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19608                Err(error) => return Err(error.into()),
19609            },
19610            None => table_dir.is_dir(),
19611        };
19612        if !table_exists {
19613            if created_table_ids.contains(&entry.table_id) {
19614                reconcile.push(entry.table_id);
19615                continue;
19616            }
19617            return Err(MongrelError::NotFound(format!(
19618                "catalog table {} storage is missing",
19619                entry.table_id
19620            )));
19621        }
19622        let manifest_result = match durable_root {
19623            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
19624            None => crate::manifest::read(&table_dir, meta_dek),
19625        };
19626        let manifest = match manifest_result {
19627            Ok(manifest) => manifest,
19628            Err(MongrelError::Io(error))
19629                if created_table_ids.contains(&entry.table_id)
19630                    && error.kind() == std::io::ErrorKind::NotFound =>
19631            {
19632                reconcile.push(entry.table_id);
19633                continue;
19634            }
19635            Err(error) => return Err(error),
19636        };
19637        if manifest.table_id != entry.table_id {
19638            return Err(MongrelError::Conflict(format!(
19639                "catalog table {} storage identity mismatch",
19640                entry.table_id
19641            )));
19642        }
19643        let schema_result = match durable_root {
19644            Some(root) => root
19645                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
19646                .map_err(MongrelError::from),
19647            None => crate::durable_file::open_regular_nofollow(
19648                &table_dir.join(crate::engine::SCHEMA_FILENAME),
19649            ),
19650        };
19651        let file = match schema_result {
19652            Ok(file) => file,
19653            Err(MongrelError::Io(error))
19654                if created_table_ids.contains(&entry.table_id)
19655                    && error.kind() == std::io::ErrorKind::NotFound =>
19656            {
19657                reconcile.push(entry.table_id);
19658                continue;
19659            }
19660            Err(error) => return Err(error),
19661        };
19662        let length = file.metadata()?.len();
19663        if length > MAX_SCHEMA_BYTES {
19664            return Err(MongrelError::ResourceLimitExceeded {
19665                resource: "recovered schema bytes",
19666                requested: usize::try_from(length).unwrap_or(usize::MAX),
19667                limit: MAX_SCHEMA_BYTES as usize,
19668            });
19669        }
19670        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
19671            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
19672        if manifest.schema_id != entry.schema.schema_id
19673            || crate::wal::DdlOp::encode_schema(&disk_schema)?
19674                != crate::wal::DdlOp::encode_schema(&entry.schema)?
19675        {
19676            reconcile.push(entry.table_id);
19677        }
19678    }
19679    for table_id in ttl_updates.keys() {
19680        if !catalog.tables.iter().any(|entry| {
19681            entry.table_id == *table_id
19682                && matches!(entry.state, TableState::Live | TableState::Building { .. })
19683        }) {
19684            continue;
19685        }
19686        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
19687        let table_exists = match durable_root {
19688            Some(root) => match root.open_directory(&relative_dir) {
19689                Ok(_) => true,
19690                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19691                Err(error) => return Err(error.into()),
19692            },
19693            None => root.join(&relative_dir).is_dir(),
19694        };
19695        if !table_exists && !created_table_ids.contains(table_id) {
19696            return Err(MongrelError::NotFound(format!(
19697                "TTL recovery table {table_id} storage is missing"
19698            )));
19699        }
19700    }
19701    reconcile.sort_unstable();
19702    reconcile.dedup();
19703    Ok(reconcile)
19704}
19705
19706fn validate_catalog_table_storage(
19707    root: &crate::durable_file::DurableRoot,
19708    catalog: &Catalog,
19709    meta_dek: Option<&[u8; META_DEK_LEN]>,
19710) -> Result<()> {
19711    for entry in &catalog.tables {
19712        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19713            continue;
19714        }
19715        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19716        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
19717        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
19718            return Err(MongrelError::Conflict(format!(
19719                "catalog table {} storage identity mismatch",
19720                entry.table_id
19721            )));
19722        }
19723        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
19724    }
19725    Ok(())
19726}
19727
19728fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
19729    match schema.columns.iter_mut().find(|c| c.id == column.id) {
19730        Some(existing) if *existing == column => Ok(false),
19731        Some(existing) => {
19732            *existing = column;
19733            schema.schema_id = schema
19734                .schema_id
19735                .checked_add(1)
19736                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19737            Ok(true)
19738        }
19739        None => {
19740            schema.columns.push(column);
19741            schema.schema_id = schema
19742                .schema_id
19743                .checked_add(1)
19744                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19745            Ok(true)
19746        }
19747    }
19748}
19749
19750fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
19751    use crate::auth::Permission;
19752    match permission {
19753        Permission::Select { table }
19754        | Permission::Insert { table }
19755        | Permission::Update { table }
19756        | Permission::Delete { table }
19757        | Permission::SelectColumns { table, .. }
19758        | Permission::InsertColumns { table, .. }
19759        | Permission::UpdateColumns { table, .. } => Some(table),
19760        Permission::All | Permission::Ddl | Permission::Admin => None,
19761    }
19762}
19763
19764fn apply_rebuilding_publish(
19765    catalog: &mut Catalog,
19766    table_id: u64,
19767    replaced_table_id: u64,
19768    new_name: &str,
19769    epoch: u64,
19770) -> Result<bool> {
19771    let already_published = catalog.tables.iter().any(|entry| {
19772        entry.table_id == table_id
19773            && entry.name == new_name
19774            && matches!(entry.state, TableState::Live)
19775    }) && catalog.tables.iter().any(|entry| {
19776        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
19777    });
19778    if already_published {
19779        return Ok(false);
19780    }
19781    let schema = catalog
19782        .tables
19783        .iter()
19784        .find(|entry| entry.table_id == table_id)
19785        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
19786        .schema
19787        .clone();
19788    let replaced = catalog
19789        .tables
19790        .iter_mut()
19791        .find(|entry| entry.table_id == replaced_table_id)
19792        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
19793    replaced.state = TableState::Dropped { at_epoch: epoch };
19794    let replacement = catalog
19795        .tables
19796        .iter_mut()
19797        .find(|entry| entry.table_id == table_id)
19798        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
19799    replacement.name = new_name.to_string();
19800    replacement.state = TableState::Live;
19801
19802    for role in &mut catalog.roles {
19803        role.permissions.retain_mut(|permission| {
19804            retain_rebuilt_permission_columns(permission, new_name, &schema)
19805        });
19806    }
19807    for definition in &mut catalog.materialized_views {
19808        if let Some(incremental) = definition.incremental.as_mut() {
19809            if incremental.source_table == new_name
19810                && incremental.source_table_id == replaced_table_id
19811            {
19812                incremental.source_table_id = table_id;
19813            }
19814        }
19815    }
19816    advance_security_version(catalog)?;
19817    Ok(true)
19818}
19819
19820fn retain_rebuilt_permission_columns(
19821    permission: &mut crate::auth::Permission,
19822    target_table: &str,
19823    schema: &Schema,
19824) -> bool {
19825    use crate::auth::Permission;
19826    let columns = match permission {
19827        Permission::SelectColumns { table, columns }
19828        | Permission::InsertColumns { table, columns }
19829        | Permission::UpdateColumns { table, columns }
19830            if table == target_table =>
19831        {
19832            Some(columns)
19833        }
19834        _ => None,
19835    };
19836    if let Some(columns) = columns {
19837        columns.retain(|column| schema.column(column).is_some());
19838        !columns.is_empty()
19839    } else {
19840        true
19841    }
19842}
19843
19844fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
19845    use crate::auth::Permission;
19846    let table = match permission {
19847        Permission::Select { table }
19848        | Permission::Insert { table }
19849        | Permission::Update { table }
19850        | Permission::Delete { table }
19851        | Permission::SelectColumns { table, .. }
19852        | Permission::InsertColumns { table, .. }
19853        | Permission::UpdateColumns { table, .. } => Some(table),
19854        Permission::All | Permission::Ddl | Permission::Admin => None,
19855    };
19856    if let Some(table) = table.filter(|table| table.as_str() == old) {
19857        *table = new.to_string();
19858    }
19859}
19860
19861fn rename_permission_column(
19862    permission: &mut crate::auth::Permission,
19863    target_table: &str,
19864    old: &str,
19865    new: &str,
19866) {
19867    use crate::auth::Permission;
19868    let columns = match permission {
19869        Permission::SelectColumns { table, columns }
19870        | Permission::InsertColumns { table, columns }
19871        | Permission::UpdateColumns { table, columns }
19872            if table == target_table =>
19873        {
19874            Some(columns)
19875        }
19876        _ => None,
19877    };
19878    if let Some(column) = columns
19879        .into_iter()
19880        .flatten()
19881        .find(|column| column.as_str() == old)
19882    {
19883        *column = new.to_string();
19884    }
19885}
19886
19887pub(crate) fn validate_security_catalog(
19888    catalog: &Catalog,
19889    security: &crate::security::SecurityCatalog,
19890) -> Result<()> {
19891    let mut policy_names = HashSet::new();
19892    for table in &security.rls_tables {
19893        if catalog.live(table).is_none() {
19894            return Err(MongrelError::NotFound(format!(
19895                "RLS table {table:?} not found"
19896            )));
19897        }
19898    }
19899    for policy in &security.policies {
19900        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
19901            return Err(MongrelError::InvalidArgument(format!(
19902                "duplicate policy {:?} on {:?}",
19903                policy.name, policy.table
19904            )));
19905        }
19906        let schema = &catalog
19907            .live(&policy.table)
19908            .ok_or_else(|| {
19909                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
19910            })?
19911            .schema;
19912        if let Some(expression) = &policy.using {
19913            validate_security_expression(expression, schema)?;
19914        }
19915        if let Some(expression) = &policy.with_check {
19916            validate_security_expression(expression, schema)?;
19917        }
19918    }
19919    let mut mask_names = HashSet::new();
19920    for mask in &security.masks {
19921        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
19922            return Err(MongrelError::InvalidArgument(format!(
19923                "duplicate mask {:?} on {:?}",
19924                mask.name, mask.table
19925            )));
19926        }
19927        let column = catalog
19928            .live(&mask.table)
19929            .and_then(|entry| {
19930                entry
19931                    .schema
19932                    .columns
19933                    .iter()
19934                    .find(|column| column.id == mask.column)
19935            })
19936            .ok_or_else(|| {
19937                MongrelError::NotFound(format!(
19938                    "mask column {} on {:?} not found",
19939                    mask.column, mask.table
19940                ))
19941            })?;
19942        if matches!(
19943            mask.strategy,
19944            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
19945        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
19946        {
19947            return Err(MongrelError::InvalidArgument(format!(
19948                "mask {:?} requires a string/bytes column",
19949                mask.name
19950            )));
19951        }
19952    }
19953    Ok(())
19954}
19955
19956fn validate_security_expression(
19957    expression: &crate::security::SecurityExpr,
19958    schema: &Schema,
19959) -> Result<()> {
19960    use crate::security::SecurityExpr;
19961    match expression {
19962        SecurityExpr::True => Ok(()),
19963        SecurityExpr::ColumnEqCurrentUser { column }
19964        | SecurityExpr::ColumnEqValue { column, .. } => {
19965            if schema
19966                .columns
19967                .iter()
19968                .any(|candidate| candidate.id == *column)
19969            {
19970                Ok(())
19971            } else {
19972                Err(MongrelError::InvalidArgument(format!(
19973                    "security expression references unknown column id {column}"
19974                )))
19975            }
19976        }
19977        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
19978            validate_security_expression(left, schema)?;
19979            validate_security_expression(right, schema)
19980        }
19981        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
19982    }
19983}
19984
19985/// Remove canonical numeric table directories that no catalog generation owns.
19986fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
19987    let referenced = cat
19988        .tables
19989        .iter()
19990        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
19991        .map(|entry| entry.table_id)
19992        .collect::<HashSet<_>>();
19993    let tables_dir = root.join(TABLES_DIR);
19994    let entries = match std::fs::read_dir(&tables_dir) {
19995        Ok(entries) => entries,
19996        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
19997        Err(error) => return Err(error.into()),
19998    };
19999    for entry in entries {
20000        let entry = entry?;
20001        if !entry.file_type()?.is_dir() {
20002            continue;
20003        }
20004        let file_name = entry.file_name();
20005        let Some(name) = file_name.to_str() else {
20006            continue;
20007        };
20008        let Ok(table_id) = name.parse::<u64>() else {
20009            continue;
20010        };
20011        if name != table_id.to_string() {
20012            continue;
20013        }
20014        if !referenced.contains(&table_id) {
20015            crate::durable_file::remove_directory_all(&entry.path())?;
20016        }
20017    }
20018    Ok(())
20019}
20020
20021/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
20022/// #14). These dirs hold pending uniform-epoch runs from large transactions
20023/// that were aborted or crashed before commit. On open, all such dirs are safe
20024/// to remove because committed txns moved their runs to `_runs/` at publish.
20025fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
20026    for entry in &cat.tables {
20027        let txn_dir = root
20028            .join(TABLES_DIR)
20029            .join(entry.table_id.to_string())
20030            .join("_txn");
20031        if txn_dir.exists() {
20032            let _ = std::fs::remove_dir_all(&txn_dir);
20033        }
20034    }
20035}
20036
20037#[cfg(test)]
20038mod write_permission_tests {
20039    use super::*;
20040    use crate::txn::Staged;
20041
20042    struct NoopExternalBridge;
20043
20044    impl ExternalTriggerBridge for NoopExternalBridge {
20045        fn apply_trigger_external_write(
20046            &self,
20047            _entry: &ExternalTableEntry,
20048            base_state: Vec<u8>,
20049            _op: ExternalTriggerWrite,
20050        ) -> Result<ExternalTriggerWriteResult> {
20051            Ok(ExternalTriggerWriteResult::new(base_state))
20052        }
20053    }
20054
20055    fn assert_txn_namespace_full<T>(result: Result<T>) {
20056        assert!(matches!(result, Err(MongrelError::Full(_))));
20057    }
20058
20059    #[test]
20060    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
20061        let directory = tempfile::tempdir().unwrap();
20062        let database = Database::create(directory.path()).unwrap();
20063        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
20064        *database.next_txn_id.lock() = generation << 32;
20065        let before = crate::wal::SharedWal::replay(directory.path())
20066            .unwrap()
20067            .len();
20068        let bridge = NoopExternalBridge;
20069
20070        assert_txn_namespace_full(database.begin().commit());
20071        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
20072        assert_txn_namespace_full(
20073            database
20074                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
20075                .commit(),
20076        );
20077        assert_txn_namespace_full(
20078            database
20079                .begin_with_external_trigger_bridge(&bridge)
20080                .commit(),
20081        );
20082        assert_txn_namespace_full(
20083            database
20084                .begin_with_external_trigger_bridge_as(&bridge, None)
20085                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
20086        );
20087
20088        assert_eq!(
20089            crate::wal::SharedWal::replay(directory.path())
20090                .unwrap()
20091                .len(),
20092            before
20093        );
20094        drop(database);
20095        Database::open(directory.path()).unwrap();
20096    }
20097
20098    #[test]
20099    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
20100        let directory = tempfile::tempdir().unwrap();
20101        let table_dir = directory.path().join("7");
20102        crate::durable_file::create_directory_all(&table_dir).unwrap();
20103        let original_schema = test_schema();
20104        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
20105        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
20106        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
20107        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
20108        let original_bytes = std::fs::read(&schema_path).unwrap();
20109
20110        let mut replacement_schema = original_schema;
20111        replacement_schema.schema_id += 1;
20112        assert!(matches!(
20113            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
20114            Err(MongrelError::Conflict(_))
20115        ));
20116
20117        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
20118        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
20119        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
20120        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
20121    }
20122
20123    #[test]
20124    fn catalog_table_missing_storage_fails_without_recreating_it() {
20125        let directory = tempfile::tempdir().unwrap();
20126        let table_dir = {
20127            let database = Database::create(directory.path()).unwrap();
20128            database.create_table("docs", test_schema()).unwrap();
20129            directory
20130                .path()
20131                .join(TABLES_DIR)
20132                .join(database.table_id("docs").unwrap().to_string())
20133        };
20134        std::fs::remove_dir_all(&table_dir).unwrap();
20135
20136        assert!(matches!(
20137            Database::open(directory.path()),
20138            Err(MongrelError::NotFound(_))
20139        ));
20140        assert!(!table_dir.exists());
20141    }
20142
20143    #[test]
20144    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
20145        let directory = tempfile::tempdir().unwrap();
20146        let database = std::sync::Arc::new(
20147            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
20148        );
20149        database.create_user("alice", "old-password").unwrap();
20150        let old_identity = database.user_identity("alice").unwrap();
20151        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
20152        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
20153        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
20154        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
20155
20156        std::thread::scope(|scope| {
20157            let authenticate = {
20158                let database = std::sync::Arc::clone(&database);
20159                scope.spawn(move || {
20160                    database.authenticate_principal_inner("alice", "old-password", || {
20161                        verified_tx.send(()).unwrap();
20162                        resume_rx.recv().unwrap();
20163                    })
20164                })
20165            };
20166            verified_rx.recv().unwrap();
20167            let mutate = {
20168                let database = std::sync::Arc::clone(&database);
20169                scope.spawn(move || {
20170                    mutation_started_tx.send(()).unwrap();
20171                    database.drop_user("alice").unwrap();
20172                    database.create_user("alice", "new-password").unwrap();
20173                    mutation_done_tx.send(()).unwrap();
20174                })
20175            };
20176            mutation_started_rx.recv().unwrap();
20177            assert!(mutation_done_rx
20178                .recv_timeout(std::time::Duration::from_millis(50))
20179                .is_err());
20180            resume_tx.send(()).unwrap();
20181            let principal = authenticate.join().unwrap().unwrap().unwrap();
20182            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
20183            mutate.join().unwrap();
20184        });
20185
20186        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
20187        assert!(database
20188            .authenticate_principal("alice", "old-password")
20189            .unwrap()
20190            .is_none());
20191        assert!(database
20192            .authenticate_principal("alice", "new-password")
20193            .unwrap()
20194            .is_some());
20195    }
20196
20197    #[test]
20198    fn homogeneous_batch_summarizes_to_one_permission_decision() {
20199        let staging = (0..10_050)
20200            .map(|_| {
20201                (
20202                    7,
20203                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
20204                )
20205            })
20206            .collect::<Vec<_>>();
20207
20208        let needs = summarize_write_permissions(&staging);
20209        let table = needs.get(&7).unwrap();
20210        assert_eq!(needs.len(), 1);
20211        assert!(table.insert);
20212        assert_eq!(table.insert_columns, [1, 2]);
20213        assert!(!table.update);
20214        assert!(!table.delete);
20215        assert!(!table.truncate);
20216    }
20217
20218    #[test]
20219    fn mixed_writes_union_columns_and_preserve_empty_operations() {
20220        let staging = vec![
20221            (7, Staged::Put(vec![(2, Value::Int64(2))])),
20222            (7, Staged::Put(vec![(1, Value::Int64(1))])),
20223            (
20224                7,
20225                Staged::Update {
20226                    row_id: RowId(1),
20227                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
20228                    changed_columns: vec![2],
20229                },
20230            ),
20231            (7, Staged::Delete(RowId(2))),
20232            (8, Staged::Truncate),
20233        ];
20234
20235        let needs = summarize_write_permissions(&staging);
20236        let table = needs.get(&7).unwrap();
20237        assert_eq!(table.insert_columns, [1, 2]);
20238        assert!(table.update);
20239        assert_eq!(table.update_columns, [2]);
20240        assert!(table.delete);
20241        assert!(needs.get(&8).unwrap().truncate);
20242    }
20243
20244    #[test]
20245    fn final_permission_decisions_do_not_scale_with_rows() {
20246        let credentialless_dir = tempfile::tempdir().unwrap();
20247        let credentialless = Database::create(credentialless_dir.path()).unwrap();
20248        credentialless.create_table("docs", test_schema()).unwrap();
20249        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20250        credentialless
20251            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
20252            .unwrap();
20253        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
20254
20255        let authenticated_dir = tempfile::tempdir().unwrap();
20256        let authenticated =
20257            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
20258                .unwrap();
20259        authenticated.create_table("docs", test_schema()).unwrap();
20260        let admin = authenticated.resolve_principal("admin").unwrap();
20261        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20262        authenticated
20263            .validate_write_permissions(
20264                &puts(authenticated.table_id("docs").unwrap()),
20265                Some(&admin),
20266                None,
20267            )
20268            .unwrap();
20269        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20270    }
20271
20272    #[test]
20273    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
20274        let dir = tempfile::tempdir().unwrap();
20275        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20276        db.create_table("docs", test_schema()).unwrap();
20277        let admin = db.resolve_principal("admin").unwrap();
20278        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20279
20280        let mut transaction = db.begin_as(Some(admin));
20281        transaction
20282            .delete_batch("docs", (0..100).map(RowId).collect())
20283            .unwrap();
20284        transaction.commit().unwrap();
20285
20286        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
20287    }
20288
20289    #[test]
20290    fn truncate_validation_checks_admin_once_for_all_tables() {
20291        let dir = tempfile::tempdir().unwrap();
20292        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20293        db.create_table("first", test_schema()).unwrap();
20294        db.create_table("second", test_schema()).unwrap();
20295        let admin = db.resolve_principal("admin").unwrap();
20296        let staging = vec![
20297            (db.table_id("first").unwrap(), Staged::Truncate),
20298            (db.table_id("second").unwrap(), Staged::Truncate),
20299        ];
20300
20301        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20302        db.validate_write_permissions(&staging, Some(&admin), None)
20303            .unwrap();
20304        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20305    }
20306
20307    #[test]
20308    fn one_table_commit_batches_structural_work() {
20309        let dir = tempfile::tempdir().unwrap();
20310        let db = Database::create(dir.path()).unwrap();
20311        db.create_table("docs", test_schema()).unwrap();
20312        let table_id = db.table_id("docs").unwrap();
20313
20314        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
20315        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20316        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20317        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20318        db.transaction(|transaction| {
20319            for id in 0..100 {
20320                transaction.put("docs", vec![(1, Value::Int64(id))])?;
20321            }
20322            Ok(())
20323        })
20324        .unwrap();
20325
20326        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
20327        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20328        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20329        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20330
20331        let puts = crate::wal::SharedWal::replay(dir.path())
20332            .unwrap()
20333            .into_iter()
20334            .filter_map(|record| match record.op {
20335                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
20336                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
20337                        .unwrap()
20338                        .len(),
20339                ),
20340                _ => None,
20341            })
20342            .collect::<Vec<_>>();
20343        assert_eq!(puts, [100]);
20344
20345        let row_ids = db
20346            .table("docs")
20347            .unwrap()
20348            .lock()
20349            .visible_rows(db.snapshot().0)
20350            .unwrap()
20351            .into_iter()
20352            .take(2)
20353            .map(|row| row.row_id)
20354            .collect::<Vec<_>>();
20355        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20356        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20357        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20358        db.transaction(|transaction| {
20359            for row_id in row_ids {
20360                transaction.delete("docs", row_id)?;
20361            }
20362            Ok(())
20363        })
20364        .unwrap();
20365        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20366        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20367        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20368
20369        let deletes = crate::wal::SharedWal::replay(dir.path())
20370            .unwrap()
20371            .into_iter()
20372            .filter_map(|record| match record.op {
20373                crate::wal::Op::Delete {
20374                    table_id: id,
20375                    row_ids,
20376                } if id == table_id => Some(row_ids.len()),
20377                _ => None,
20378            })
20379            .collect::<Vec<_>>();
20380        assert_eq!(deletes, [2]);
20381    }
20382
20383    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
20384        (0..10_050)
20385            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
20386            .collect()
20387    }
20388
20389    fn test_schema() -> Schema {
20390        Schema {
20391            columns: vec![ColumnDef {
20392                id: 1,
20393                name: "id".into(),
20394                ty: TypeId::Int64,
20395                flags: crate::schema::ColumnFlags::empty()
20396                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20397                default_value: None,
20398                embedding_source: None,
20399            }],
20400            ..Schema::default()
20401        }
20402    }
20403}
20404
20405#[cfg(test)]
20406mod cdc_bounds_tests {
20407    use super::*;
20408
20409    #[test]
20410    fn retained_byte_limit_rejects_without_allocating_payload() {
20411        let mut retained = 0;
20412        let error = charge_cdc_bytes(
20413            &mut retained,
20414            CDC_MAX_RETAINED_BYTES.saturating_add(1),
20415            "CDC retained bytes",
20416        )
20417        .unwrap_err();
20418        assert!(matches!(
20419            error,
20420            MongrelError::ResourceLimitExceeded {
20421                resource: "CDC retained bytes",
20422                ..
20423            }
20424        ));
20425    }
20426
20427    #[test]
20428    fn row_json_estimate_accounts_for_byte_array_expansion() {
20429        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
20430            .with_column(1, Value::Bytes(vec![0; 1024]));
20431        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
20432    }
20433}
20434
20435#[cfg(test)]
20436mod generation_metrics_tests {
20437    use super::*;
20438    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20439
20440    #[test]
20441    fn legacy_cow_fallback_is_measured() {
20442        let dir = tempfile::tempdir().unwrap();
20443        let table = Table::create(
20444            dir.path(),
20445            Schema {
20446                columns: vec![ColumnDef {
20447                    id: 1,
20448                    name: "id".into(),
20449                    ty: TypeId::Int64,
20450                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20451                    default_value: None,
20452                    embedding_source: None,
20453                }],
20454                ..Schema::default()
20455            },
20456            1,
20457        )
20458        .unwrap();
20459        let handle = TableHandle::from_table(table);
20460        let held = match &handle.inner {
20461            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
20462            TableHandleInner::Direct(_) => unreachable!(),
20463        };
20464
20465        handle.lock().set_sync_byte_threshold(1);
20466
20467        let stats = handle.generation_stats();
20468        assert_eq!(stats.cow_clone_count, 1);
20469        assert!(stats.estimated_cow_clone_bytes > 0);
20470        drop(held);
20471    }
20472}
20473
20474#[cfg(test)]
20475mod trigger_engine_tests {
20476    use super::*;
20477
20478    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
20479        WriteEvent {
20480            table: "test".into(),
20481            kind: TriggerEvent::Insert,
20482            new: Some(TriggerRowImage {
20483                columns: new_cells.iter().cloned().collect(),
20484            }),
20485            old: Some(TriggerRowImage {
20486                columns: old_cells.iter().cloned().collect(),
20487            }),
20488            changed_columns: Vec::new(),
20489            op_indices: Vec::new(),
20490            put_idx: None,
20491            trigger_stack: Vec::new(),
20492        }
20493    }
20494
20495    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
20496        WriteEvent {
20497            table: "test".into(),
20498            kind: TriggerEvent::Insert,
20499            new: Some(TriggerRowImage {
20500                columns: new_cells.iter().cloned().collect(),
20501            }),
20502            old: None,
20503            changed_columns: Vec::new(),
20504            op_indices: Vec::new(),
20505            put_idx: None,
20506            trigger_stack: Vec::new(),
20507        }
20508    }
20509
20510    #[test]
20511    fn value_order_int64_vs_float64() {
20512        assert_eq!(
20513            value_order(&Value::Int64(5), &Value::Float64(5.0)),
20514            Some(std::cmp::Ordering::Equal)
20515        );
20516        assert_eq!(
20517            value_order(&Value::Int64(5), &Value::Float64(3.0)),
20518            Some(std::cmp::Ordering::Greater)
20519        );
20520        assert_eq!(
20521            value_order(&Value::Int64(2), &Value::Float64(3.0)),
20522            Some(std::cmp::Ordering::Less)
20523        );
20524    }
20525
20526    #[test]
20527    fn value_order_null_returns_none() {
20528        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
20529        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
20530        assert_eq!(value_order(&Value::Null, &Value::Null), None);
20531    }
20532
20533    #[test]
20534    fn value_order_cross_group_returns_none() {
20535        assert_eq!(
20536            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
20537            None
20538        );
20539        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
20540        assert_eq!(
20541            value_order(
20542                &Value::Embedding(vec![1.0, 2.0]),
20543                &Value::Embedding(vec![1.0, 2.0])
20544            ),
20545            None
20546        );
20547    }
20548
20549    #[test]
20550    fn eval_trigger_expr_ranges_and_booleans() {
20551        let expr = TriggerExpr::And {
20552            left: Box::new(TriggerExpr::Gt {
20553                left: TriggerValue::NewColumn(1),
20554                right: TriggerValue::Literal(Value::Int64(0)),
20555            }),
20556            right: Box::new(TriggerExpr::Lte {
20557                left: TriggerValue::NewColumn(1),
20558                right: TriggerValue::Literal(Value::Int64(100)),
20559            }),
20560        };
20561        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
20562        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
20563        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
20564
20565        let or_expr = TriggerExpr::Or {
20566            left: Box::new(TriggerExpr::Lt {
20567                left: TriggerValue::NewColumn(1),
20568                right: TriggerValue::Literal(Value::Int64(0)),
20569            }),
20570            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
20571                TriggerValue::OldColumn(2),
20572            )))),
20573        };
20574        assert!(eval_trigger_expr(
20575            &or_expr,
20576            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
20577        )
20578        .unwrap());
20579        assert!(!eval_trigger_expr(
20580            &or_expr,
20581            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
20582        )
20583        .unwrap());
20584
20585        assert!(eval_trigger_expr(
20586            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
20587            &event_insert(&[])
20588        )
20589        .unwrap());
20590        assert!(!eval_trigger_expr(
20591            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
20592            &event_insert(&[])
20593        )
20594        .unwrap());
20595        assert!(!eval_trigger_expr(
20596            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
20597            &event_insert(&[])
20598        )
20599        .unwrap());
20600    }
20601}
20602
20603#[cfg(test)]
20604mod core_resource_tests {
20605    use super::*;
20606
20607    fn int_pk_schema() -> Schema {
20608        Schema {
20609            columns: vec![ColumnDef {
20610                id: 1,
20611                name: "id".into(),
20612                ty: TypeId::Int64,
20613                flags: crate::schema::ColumnFlags::empty()
20614                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20615                default_value: None,
20616                embedding_source: None,
20617            }],
20618            ..Schema::default()
20619        }
20620    }
20621
20622    #[test]
20623    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
20624        let dir = tempfile::tempdir().unwrap();
20625        let db = Database::create(dir.path()).unwrap();
20626        assert_eq!(
20627            db.memory_governor().max_bytes(),
20628            DEFAULT_MEMORY_BUDGET_BYTES
20629        );
20630        assert_eq!(
20631            db.spill_manager().config().global_bytes,
20632            DEFAULT_TEMP_DISK_BUDGET_BYTES
20633        );
20634        assert!(db.job_registry().list().is_empty());
20635        // S1E-002: class defaults seeded at open; no external embedding vendor.
20636        assert_eq!(
20637            db.resource_groups().len(),
20638            crate::resource::WorkloadClass::ALL.len()
20639        );
20640        assert!(db.resource_groups().get("control").is_some());
20641        assert!(db.embedding_providers().list_ids().is_empty());
20642        // Application-supplied path refuses generation (no silent hashed vectors).
20643        let err = db
20644            .embedding_providers()
20645            .embed(
20646                &crate::embedding::EmbeddingSource::SuppliedByApplication,
20647                &["text"],
20648                4,
20649            )
20650            .unwrap_err();
20651        assert!(matches!(
20652            err,
20653            crate::embedding::EmbeddingError::SuppliedByApplication
20654        ));
20655    }
20656
20657    #[test]
20658    fn lock_rows_for_update_acquires_exclusive_row_locks() {
20659        use crate::locks::LockKey;
20660        use crate::rowid::RowId;
20661
20662        let dir = tempfile::tempdir().unwrap();
20663        let db = Database::create(dir.path()).unwrap();
20664        let txn_id = db.allocate_lock_txn_id().unwrap();
20665        let rid = RowId(42);
20666        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
20667        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20668        db.release_txn_locks(txn_id);
20669        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20670    }
20671
20672    #[test]
20673    fn open_with_options_sizes_the_core_budgets() {
20674        let dir = tempfile::tempdir().unwrap();
20675        let db = Database::create(dir.path()).unwrap();
20676        drop(db);
20677        let db = Database::open_with_options(
20678            dir.path(),
20679            OpenOptions::default()
20680                .with_memory_budget_bytes(256 * 1024 * 1024)
20681                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
20682        )
20683        .unwrap();
20684        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
20685        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
20686    }
20687
20688    #[test]
20689    fn zero_budgets_are_rejected() {
20690        let dir = tempfile::tempdir().unwrap();
20691        let db = Database::create(dir.path()).unwrap();
20692        drop(db);
20693        let result = Database::open_with_options(
20694            dir.path(),
20695            OpenOptions::default().with_memory_budget_bytes(0),
20696        );
20697        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20698        let result = Database::open_with_options(
20699            dir.path(),
20700            OpenOptions::default().with_temp_disk_budget_bytes(0),
20701        );
20702        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20703    }
20704
20705    #[test]
20706    fn page_caches_reserve_under_the_governor() {
20707        let dir = tempfile::tempdir().unwrap();
20708        let db = Database::create(dir.path()).unwrap();
20709        db.create_table("t", int_pk_schema()).unwrap();
20710        let mut txn = db.begin();
20711        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20712        txn.commit().unwrap();
20713        // S1E-003: both caches hold reservations under the core's governor, so
20714        // the governor's per-class accounting mirrors their live bytes, and
20715        // both are registered as reclaimable subsystems it can evict.
20716        let stats = db.memory_governor().stats();
20717        assert_eq!(
20718            stats.usage_for(crate::memory::MemoryClass::PageCache),
20719            db.page_cache.used_bytes()
20720        );
20721        assert_eq!(
20722            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
20723            db.decoded_cache.used_bytes()
20724        );
20725        assert_eq!(
20726            db.memory_governor().reclaimable_bytes(),
20727            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
20728        );
20729        // Driving an eviction through the governor is safe at any level.
20730        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
20731    }
20732
20733    #[test]
20734    fn job_registry_persists_across_reopen() {
20735        let dir = tempfile::tempdir().unwrap();
20736        let db = Database::create(dir.path()).unwrap();
20737        db.create_table("t", int_pk_schema()).unwrap();
20738        let job_id = db
20739            .job_registry()
20740            .submit(
20741                crate::jobs::JobKind::IndexBuild,
20742                crate::jobs::JobTarget {
20743                    table: "t".to_string(),
20744                    index: Some("idx".to_string()),
20745                },
20746            )
20747            .unwrap();
20748        drop(db);
20749        let db = Database::open(dir.path()).unwrap();
20750        let record = db.job_registry().get(job_id).expect("job survives reopen");
20751        assert_eq!(record.state, crate::jobs::JobState::Pending);
20752    }
20753
20754    #[test]
20755    fn spill_manager_open_sweeps_stale_temp_tree() {
20756        let dir = tempfile::tempdir().unwrap();
20757        let db = Database::create(dir.path()).unwrap();
20758        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
20759        std::fs::create_dir_all(&stale).unwrap();
20760        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
20761        drop(db);
20762        let db = Database::open(dir.path()).unwrap();
20763        assert!(
20764            !stale.exists(),
20765            "the startup sweep removes stale spill files (S1E-004)"
20766        );
20767        // A fresh session can start against the swept manager.
20768        let session = db
20769            .spill_manager()
20770            .begin_query(
20771                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
20772                1024 * 1024,
20773            )
20774            .unwrap();
20775        assert_eq!(session.used(), 0);
20776    }
20777}
20778
20779#[cfg(test)]
20780mod version_pin_tests {
20781    use super::*;
20782
20783    fn int_pk_schema() -> Schema {
20784        Schema {
20785            columns: vec![ColumnDef {
20786                id: 1,
20787                name: "id".into(),
20788                ty: TypeId::Int64,
20789                flags: crate::schema::ColumnFlags::empty()
20790                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20791                default_value: None,
20792                embedding_source: None,
20793            }],
20794            ..Schema::default()
20795        }
20796    }
20797
20798    fn pins_for(
20799        report: &[TablePinsReport],
20800        table: &str,
20801        source: crate::retention::PinSource,
20802    ) -> Option<crate::retention::PinInfo> {
20803        report
20804            .iter()
20805            .find(|entry| entry.table == table)
20806            .and_then(|entry| entry.pins.get(source).cloned())
20807    }
20808
20809    #[test]
20810    fn backup_boundary_registers_backup_pitr_pin() {
20811        let source = tempfile::tempdir().unwrap();
20812        let destination_parent = tempfile::tempdir().unwrap();
20813        let destination = destination_parent.path().join("backup");
20814        let db = Arc::new(Database::create(source.path()).unwrap());
20815        db.create_table("t", int_pk_schema()).unwrap();
20816        let mut txn = db.begin();
20817        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
20818        let boundary_epoch = txn.commit().unwrap();
20819
20820        let hold = Arc::new(std::sync::Barrier::new(2));
20821        let resume = Arc::new(std::sync::Barrier::new(2));
20822        db.__set_backup_hook({
20823            let hold = Arc::clone(&hold);
20824            let resume = Arc::clone(&resume);
20825            move || {
20826                hold.wait();
20827                resume.wait();
20828            }
20829        });
20830
20831        let backup = {
20832            let db = Arc::clone(&db);
20833            let destination = destination.clone();
20834            std::thread::spawn(move || db.hot_backup(destination))
20835        };
20836        hold.wait();
20837        // The hook fires while the backup's pins are held: the boundary must
20838        // show up as a BackupPitr pin on the table's unified registry.
20839        let report = db.version_pins_report();
20840        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
20841            .expect("backup boundary must register a BackupPitr pin");
20842        assert_eq!(pin.oldest_epoch, boundary_epoch);
20843        assert!(pin.pin_count >= 1);
20844        resume.wait();
20845        backup.join().unwrap().unwrap();
20846
20847        let report = db.version_pins_report();
20848        assert!(
20849            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
20850            "the BackupPitr pin releases when the backup finishes"
20851        );
20852    }
20853
20854    #[test]
20855    fn snapshot_and_read_generation_pins_surface_in_report() {
20856        let dir = tempfile::tempdir().unwrap();
20857        let db = Database::create(dir.path()).unwrap();
20858        db.create_table("t", int_pk_schema()).unwrap();
20859        let mut txn = db.begin();
20860        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20861        txn.commit().unwrap();
20862
20863        let (_snapshot, guard) = db.snapshot();
20864        let report = db.version_pins_report();
20865        assert!(
20866            pins_for(
20867                &report,
20868                "t",
20869                crate::retention::PinSource::TransactionSnapshot
20870            )
20871            .is_some(),
20872            "a database snapshot projects the TransactionSnapshot source"
20873        );
20874        drop(guard);
20875
20876        let handle = db.table("t").unwrap();
20877        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
20878        let report = db.version_pins_report();
20879        assert!(
20880            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
20881            "a cloned read generation registers a ReadGeneration pin"
20882        );
20883        drop(generation);
20884
20885        let report = db.version_pins_report();
20886        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
20887        assert!(
20888            entry
20889                .pins
20890                .get(crate::retention::PinSource::BackupPitr)
20891                .is_none()
20892                && entry
20893                    .pins
20894                    .get(crate::retention::PinSource::Replication)
20895                    .is_none()
20896                && entry
20897                    .pins
20898                    .get(crate::retention::PinSource::OnlineIndexBuild)
20899                    .is_none(),
20900            "untaken sources stay absent from the report"
20901        );
20902    }
20903}
20904
20905#[cfg(test)]
20906mod lock_manager_tests {
20907    use super::*;
20908    use crate::locks::LockKey;
20909
20910    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
20911        ColumnDef {
20912            id,
20913            name: name.into(),
20914            ty,
20915            flags,
20916            default_value: None,
20917            embedding_source: None,
20918        }
20919    }
20920
20921    fn unique_schema() -> Schema {
20922        let mut constraints = crate::constraint::TableConstraints::default();
20923        constraints
20924            .uniques
20925            .push(crate::constraint::UniqueConstraint {
20926                id: 1,
20927                name: "users_email_unique".into(),
20928                columns: vec![1],
20929            });
20930        Schema {
20931            columns: vec![
20932                col(
20933                    0,
20934                    "id",
20935                    TypeId::Int64,
20936                    crate::schema::ColumnFlags::empty()
20937                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20938                ),
20939                col(
20940                    1,
20941                    "email",
20942                    TypeId::Bytes,
20943                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
20944                ),
20945            ],
20946            constraints,
20947            ..Schema::default()
20948        }
20949    }
20950
20951    fn parent_schema() -> Schema {
20952        Schema {
20953            columns: vec![col(
20954                0,
20955                "id",
20956                TypeId::Int64,
20957                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
20958            )],
20959            ..Schema::default()
20960        }
20961    }
20962
20963    fn child_schema() -> Schema {
20964        let mut constraints = crate::constraint::TableConstraints::default();
20965        constraints
20966            .foreign_keys
20967            .push(crate::constraint::ForeignKey {
20968                id: 1,
20969                name: "child_parent_fk".into(),
20970                columns: vec![1],
20971                ref_table: "parent".into(),
20972                ref_columns: vec![0],
20973                on_delete: crate::constraint::FkAction::Restrict,
20974                on_update: crate::constraint::FkAction::Restrict,
20975            });
20976        Schema {
20977            columns: vec![
20978                col(
20979                    0,
20980                    "id",
20981                    TypeId::Int64,
20982                    crate::schema::ColumnFlags::empty()
20983                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20984                ),
20985                col(
20986                    1,
20987                    "pid",
20988                    TypeId::Int64,
20989                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
20990                ),
20991            ],
20992            constraints,
20993            ..Schema::default()
20994        }
20995    }
20996
20997    fn auto_inc_schema() -> Schema {
20998        Schema {
20999            columns: vec![col(
21000                0,
21001                "id",
21002                TypeId::Int64,
21003                crate::schema::ColumnFlags::empty()
21004                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
21005                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
21006            )],
21007            ..Schema::default()
21008        }
21009    }
21010
21011    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
21012        let mut key = b"pk:".to_vec();
21013        key.extend_from_slice(&Value::Int64(value).encode_key());
21014        LockKey::key(table_id, key)
21015    }
21016
21017    #[test]
21018    fn unique_claims_serialize_concurrent_commits() {
21019        let dir = tempfile::tempdir().unwrap();
21020        let db = Arc::new(Database::create(dir.path()).unwrap());
21021        let table_id = db.create_table("users", unique_schema()).unwrap();
21022        let pk_key = pk_lock_key(table_id, 1);
21023        let entered = Arc::new(std::sync::Barrier::new(2));
21024        let resume = Arc::new(std::sync::Barrier::new(2));
21025        let parked = Arc::new(AtomicBool::new(false));
21026        db.__set_catalog_commit_hook({
21027            let entered = Arc::clone(&entered);
21028            let resume = Arc::clone(&resume);
21029            let parked = Arc::clone(&parked);
21030            move || {
21031                // Park only the first commit to reach the sequencer; later
21032                // commits pass straight through.
21033                if !parked.swap(true, Ordering::SeqCst) {
21034                    entered.wait();
21035                    resume.wait();
21036                }
21037            }
21038        });
21039
21040        let mut txn_a = db.begin();
21041        txn_a
21042            .put(
21043                "users",
21044                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21045            )
21046            .unwrap();
21047        let a_id = txn_a.txn_id();
21048        let (a_tx, a_rx) = std::sync::mpsc::channel();
21049        let (b_tx, b_rx) = std::sync::mpsc::channel();
21050        std::thread::scope(|scope| {
21051            scope.spawn(|| {
21052                a_tx.send(txn_a.commit()).unwrap();
21053            });
21054            entered.wait();
21055            // A is parked in the sequencer holding its unique claims.
21056            assert!(
21057                db.lock_manager().holds(a_id, &pk_key),
21058                "primary-key claim must be held until the commit ends"
21059            );
21060            let mut uq_key = format!("uq{}:", 1).into_bytes();
21061            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
21062                .into_iter()
21063                .collect();
21064            uq_key.extend_from_slice(
21065                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
21066            );
21067            assert!(
21068                db.lock_manager()
21069                    .holds(a_id, &LockKey::key(table_id, uq_key)),
21070                "declared-unique claim must be held until the commit ends"
21071            );
21072
21073            let mut txn_b = db.begin();
21074            txn_b
21075                .put(
21076                    "users",
21077                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
21078                )
21079                .unwrap();
21080            scope.spawn(|| {
21081                b_tx.send(txn_b.commit()).unwrap();
21082            });
21083            std::thread::sleep(std::time::Duration::from_millis(100));
21084            assert!(
21085                b_rx.try_recv().is_err(),
21086                "the concurrent claim must block until A ends its transaction"
21087            );
21088            resume.wait();
21089            assert!(a_rx.recv().unwrap().is_ok());
21090            let b_result = b_rx.recv().unwrap();
21091            assert!(
21092                matches!(b_result, Err(MongrelError::Conflict(_))),
21093                "the loser surfaces a conflict after serializing: {b_result:?}"
21094            );
21095        });
21096        assert!(
21097            !db.lock_manager().holds(a_id, &pk_key),
21098            "no phantom holds remain after the commit"
21099        );
21100    }
21101
21102    #[test]
21103    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
21104        let dir = tempfile::tempdir().unwrap();
21105        let db = Arc::new(Database::create(dir.path()).unwrap());
21106        db.create_table("parent", parent_schema()).unwrap();
21107        db.create_table("child", child_schema()).unwrap();
21108        let mut seed = db.begin();
21109        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21110        seed.commit().unwrap();
21111
21112        let entered = Arc::new(std::sync::Barrier::new(2));
21113        let resume = Arc::new(std::sync::Barrier::new(2));
21114        db.__set_fk_lock_hook({
21115            let entered = Arc::clone(&entered);
21116            let resume = Arc::clone(&resume);
21117            move || {
21118                entered.wait();
21119                resume.wait();
21120            }
21121        });
21122
21123        let mut txn_a = db.begin();
21124        txn_a
21125            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
21126            .unwrap();
21127        let a_id = txn_a.txn_id();
21128        let (a_tx, a_rx) = std::sync::mpsc::channel();
21129        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
21130        std::thread::scope(|scope| {
21131            scope.spawn(|| {
21132                a_tx.send(txn_a.commit()).unwrap();
21133            });
21134            entered.wait();
21135            // A is parked mid-validation: schema barrier held Shared.
21136            assert!(
21137                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
21138                "DML holds the schema barrier Shared for its commit"
21139            );
21140            let db = Arc::clone(&db);
21141            scope.spawn(move || {
21142                ddl_tx.send(db.drop_table("parent")).unwrap();
21143            });
21144            std::thread::sleep(std::time::Duration::from_millis(100));
21145            assert!(
21146                ddl_rx.try_recv().is_err(),
21147                "DDL must wait on the Exclusive schema barrier while DML is in flight"
21148            );
21149            resume.wait();
21150            // A now finishes and the waiting DDL proceeds. A's commit may
21151            // legitimately lose the publish race against the DDL's security
21152            // version advance — the designed "security policy changed during
21153            // write" outcome — or win it; the barrier guarantees only that the
21154            // DDL could not proceed before A released it.
21155            let a_result = a_rx.recv().unwrap();
21156            match &a_result {
21157                Ok(_) => {}
21158                Err(MongrelError::Conflict(message)) => {
21159                    assert!(
21160                        message.contains("security policy changed during write"),
21161                        "unexpected commit conflict: {message}"
21162                    );
21163                }
21164                other => panic!("unexpected commit outcome: {other:?}"),
21165            }
21166            assert!(ddl_rx.recv().unwrap().is_ok());
21167        });
21168        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
21169    }
21170
21171    #[test]
21172    fn auto_increment_sequence_barrier_held_until_commit() {
21173        let dir = tempfile::tempdir().unwrap();
21174        let db = Database::create(dir.path()).unwrap();
21175        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21176        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21177        let entered = Arc::new(std::sync::Barrier::new(2));
21178        let resume = Arc::new(std::sync::Barrier::new(2));
21179        let parked = Arc::new(AtomicBool::new(false));
21180        db.__set_catalog_commit_hook({
21181            let entered = Arc::clone(&entered);
21182            let resume = Arc::clone(&resume);
21183            let parked = Arc::clone(&parked);
21184            move || {
21185                if !parked.swap(true, Ordering::SeqCst) {
21186                    entered.wait();
21187                    resume.wait();
21188                }
21189            }
21190        });
21191
21192        let mut txn_a = db.begin();
21193        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
21194        let a_id = txn_a.txn_id();
21195        // The stage-time allocation already holds the barrier.
21196        assert!(
21197            db.lock_manager().holds(a_id, &barrier_key),
21198            "sequence allocation takes the barrier at stage time"
21199        );
21200        let (a_tx, a_rx) = std::sync::mpsc::channel();
21201        std::thread::scope(|scope| {
21202            scope.spawn(|| {
21203                a_tx.send(txn_a.commit()).unwrap();
21204            });
21205            entered.wait();
21206            assert!(
21207                db.lock_manager().holds(a_id, &barrier_key),
21208                "the barrier is held through the commit"
21209            );
21210            resume.wait();
21211            assert!(a_rx.recv().unwrap().is_ok());
21212        });
21213        assert!(
21214            !db.lock_manager().holds(a_id, &barrier_key),
21215            "the barrier releases when the commit ends"
21216        );
21217    }
21218
21219    #[test]
21220    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
21221        let dir = tempfile::tempdir().unwrap();
21222        let db = Database::create(dir.path()).unwrap();
21223        db.create_table("parent", parent_schema()).unwrap();
21224        db.create_table("child", child_schema()).unwrap();
21225        let mut seed = db.begin();
21226        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21227        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
21228        seed.commit().unwrap();
21229        let (rid1, rid2) = {
21230            let handle = db.table("parent").unwrap();
21231            let table = handle.lock();
21232            let rid = |pk: i64| {
21233                table
21234                    .lookup_pk(&Value::Int64(pk).encode_key())
21235                    .expect("seeded parent row")
21236            };
21237            (rid(1), rid(2))
21238        };
21239
21240        // The hook fires after every successful FK lock acquisition; park the
21241        // first two (A's and B's Exclusive delete-side claims) so both are
21242        // held before either transaction attempts its Shared insert-side
21243        // claim — a deterministic wait-for cycle.
21244        let rendezvous = Arc::new(std::sync::Barrier::new(2));
21245        let calls = Arc::new(AtomicUsize::new(0));
21246        db.__set_fk_lock_hook({
21247            let rendezvous = Arc::clone(&rendezvous);
21248            let calls = Arc::clone(&calls);
21249            move || {
21250                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
21251                    rendezvous.wait();
21252                }
21253            }
21254        });
21255
21256        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
21257        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
21258        let mut txn_a = db.begin();
21259        txn_a.delete("parent", rid1).unwrap();
21260        txn_a
21261            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
21262            .unwrap();
21263        let mut txn_b = db.begin();
21264        txn_b.delete("parent", rid2).unwrap();
21265        txn_b
21266            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
21267            .unwrap();
21268        let b_id = txn_b.txn_id();
21269
21270        let (a_tx, a_rx) = std::sync::mpsc::channel();
21271        let (b_tx, b_rx) = std::sync::mpsc::channel();
21272        std::thread::scope(|scope| {
21273            scope.spawn(|| {
21274                a_tx.send(txn_a.commit()).unwrap();
21275            });
21276            scope.spawn(|| {
21277                b_tx.send(txn_b.commit()).unwrap();
21278            });
21279            let a_result = a_rx.recv().unwrap();
21280            let b_result = b_rx.recv().unwrap();
21281            assert!(
21282                a_result.is_ok(),
21283                "the survivor commits once the victim releases: {a_result:?}"
21284            );
21285            match b_result {
21286                Err(MongrelError::Deadlock { victim, .. }) => {
21287                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
21288                }
21289                other => panic!("the victim must surface a deadlock, got {other:?}"),
21290            }
21291        });
21292        // No phantom holds survive the victim's release.
21293        let fk_key = |table: &str, pk: i64| {
21294            let table_id = db.table_id(table).unwrap();
21295            let mut key = b"fk:".to_vec();
21296            key.extend_from_slice(&Value::Int64(pk).encode_key());
21297            LockKey::key(table_id, key)
21298        };
21299        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
21300        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
21301    }
21302
21303    #[test]
21304    fn locks_release_after_commit_rollback_and_failed_commit() {
21305        let dir = tempfile::tempdir().unwrap();
21306        let db = Database::create(dir.path()).unwrap();
21307        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21308        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21309
21310        // Successful commit: the stage-time barrier releases with the commit.
21311        let mut txn = db.begin();
21312        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21313        let committed_id = txn.txn_id();
21314        txn.commit().unwrap();
21315        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
21316
21317        // Rollback: the stage-time barrier releases with the drop.
21318        let mut txn = db.begin();
21319        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21320        let rolled_back_id = txn.txn_id();
21321        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
21322        txn.rollback();
21323        assert!(
21324            !db.lock_manager().holds(rolled_back_id, &barrier_key),
21325            "rollback must not leave phantom holds"
21326        );
21327
21328        // Failed commit (declared-unique violation): the claims release with
21329        // the error.
21330        db.create_table("users", unique_schema()).unwrap();
21331        let users_id = db.table_id("users").unwrap();
21332        let mut seed = db.begin();
21333        seed.put(
21334            "users",
21335            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21336        )
21337        .unwrap();
21338        seed.commit().unwrap();
21339        let mut txn = db.begin();
21340        // A different primary key but the same declared-unique email: the
21341        // Phase B unique check rejects this commit.
21342        txn.put(
21343            "users",
21344            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
21345        )
21346        .unwrap();
21347        let failed_id = txn.txn_id();
21348        let result = txn.commit();
21349        assert!(matches!(result, Err(MongrelError::Conflict(_))));
21350        assert!(
21351            !db.lock_manager()
21352                .holds(failed_id, &pk_lock_key(users_id, 2)),
21353            "a failed commit must not leave phantom holds"
21354        );
21355    }
21356}
21357
21358#[cfg(test)]
21359mod lifecycle_tests {
21360    use super::*;
21361
21362    fn int_pk_schema() -> Schema {
21363        Schema {
21364            columns: vec![ColumnDef {
21365                id: 1,
21366                name: "id".into(),
21367                ty: TypeId::Int64,
21368                flags: crate::schema::ColumnFlags::empty()
21369                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21370                default_value: None,
21371                embedding_source: None,
21372            }],
21373            ..Schema::default()
21374        }
21375    }
21376
21377    #[test]
21378    fn poisoned_core_rejects_operations_with_typed_errors() {
21379        let dir = tempfile::tempdir().unwrap();
21380        let db = Database::create(dir.path()).unwrap();
21381        db.create_table("t", int_pk_schema()).unwrap();
21382        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
21383
21384        // Drive the exact two-state poison the fsync-error sites set
21385        // (write-path flag + lifecycle transition), without process-global
21386        // fault injection, which would leak into parallel tests. The fsync
21387        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
21388        db.poisoned.store(true, Ordering::Relaxed);
21389        db.lifecycle.poison();
21390        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
21391
21392        // Guarded operations without their own write-path poison check reject
21393        // at admission with the lifecycle Conflict...
21394        let error = db.gc().unwrap_err();
21395        assert!(
21396            matches!(error, MongrelError::Conflict(_)),
21397            "gc must reject on a poisoned core: {error:?}"
21398        );
21399        let error = db.compact().unwrap_err();
21400        assert!(
21401            matches!(error, MongrelError::Conflict(_)),
21402            "compact must reject on a poisoned core: {error:?}"
21403        );
21404        assert!(db.operation_guard().is_err());
21405        // ...while paths that already checked the write-path flag keep their
21406        // legacy error.
21407        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
21408        assert!(
21409            error.to_string().contains("database poisoned"),
21410            "the legacy poison error still wins where it existed: {error:?}"
21411        );
21412        let mut txn = db.begin();
21413        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
21414        assert!(txn
21415            .commit()
21416            .unwrap_err()
21417            .to_string()
21418            .contains("database poisoned"));
21419    }
21420
21421    #[test]
21422    fn shutdown_waits_for_operation_guards_to_drain() {
21423        let dir = tempfile::tempdir().unwrap();
21424        let db = Arc::new(Database::create(dir.path()).unwrap());
21425        db.create_table("t", int_pk_schema()).unwrap();
21426        // The guard holds the lifecycle's Arc, not the database's, so the
21427        // exclusive-owner shutdown can proceed to its drain step below.
21428        let guard = db.operation_guard().unwrap();
21429        let (started_tx, started_rx) = std::sync::mpsc::channel();
21430        let (done_tx, done_rx) = std::sync::mpsc::channel();
21431        let shutdown_thread = std::thread::spawn(move || {
21432            started_tx.send(()).unwrap();
21433            let result = db.shutdown();
21434            let _ = done_tx.send(result);
21435        });
21436        started_rx.recv().unwrap();
21437        std::thread::sleep(std::time::Duration::from_millis(100));
21438        assert!(
21439            done_rx.try_recv().is_err(),
21440            "shutdown must wait for the outstanding guard to drain"
21441        );
21442        drop(guard);
21443        shutdown_thread.join().unwrap();
21444        assert!(
21445            done_rx.recv().unwrap().is_ok(),
21446            "shutdown completes once the guard drops"
21447        );
21448    }
21449}
21450
21451#[cfg(test)]
21452mod commit_ts_ledger_tests {
21453    use super::*;
21454    use crate::memtable::Row;
21455
21456    fn int_pk_schema() -> Schema {
21457        Schema {
21458            columns: vec![ColumnDef {
21459                id: 1,
21460                name: "id".into(),
21461                ty: TypeId::Int64,
21462                flags: crate::schema::ColumnFlags::empty()
21463                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21464                default_value: None,
21465                embedding_source: None,
21466            }],
21467            ..Schema::default()
21468        }
21469    }
21470
21471    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
21472        let mut txn = db.begin();
21473        let handle = txn.state_handle();
21474        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
21475        let epoch = txn.commit().unwrap();
21476        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
21477            panic!("expected Committed, got {:?}", handle.state());
21478        };
21479        (epoch, receipt.commit_ts)
21480    }
21481
21482    #[test]
21483    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
21484        let dir = tempfile::tempdir().unwrap();
21485        let db = Database::create(dir.path()).unwrap();
21486        db.create_table("t", int_pk_schema()).unwrap();
21487
21488        let (epoch, commit_ts) = commit_one(&db);
21489        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
21490        // An epoch no commit sealed misses (callers fall back).
21491        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
21492    }
21493
21494    #[test]
21495    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
21496        let dir = tempfile::tempdir().unwrap();
21497        let (epoch, commit_ts) = {
21498            let db = Database::create(dir.path()).unwrap();
21499            db.create_table("t", int_pk_schema()).unwrap();
21500            commit_one(&db)
21501        };
21502
21503        let db = Database::open(dir.path()).unwrap();
21504        let reconstructed = db
21505            .commit_ts_for_epoch(epoch)
21506            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
21507        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
21508        // The ledger byte format stores micros only (spec §8.1): the logical
21509        // counter and tiebreaker reconstruct as 0.
21510        assert_eq!(reconstructed.logical, 0);
21511        assert_eq!(reconstructed.node_tiebreaker, 0);
21512    }
21513
21514    #[test]
21515    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
21516        use crate::wal::Op;
21517        let records = vec![
21518            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
21519            crate::wal::Record::new(
21520                Epoch(2),
21521                7,
21522                Op::TxnCommit {
21523                    epoch: 41,
21524                    added_runs: vec![],
21525                },
21526            ),
21527            // No CommitTimestamp for txn 8: not reconstructible.
21528            crate::wal::Record::new(
21529                Epoch(3),
21530                8,
21531                Op::TxnCommit {
21532                    epoch: 42,
21533                    added_runs: vec![],
21534                },
21535            ),
21536            // Timestamp without a commit marker: aborted, not reconstructible.
21537            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
21538        ];
21539        let ledger = commit_ts_ledger_from_recovery(&records);
21540        assert_eq!(ledger.len(), 1);
21541        assert_eq!(
21542            ledger.get(&41),
21543            Some(&mongreldb_types::hlc::HlcTimestamp {
21544                physical_micros: 1,
21545                logical: 0,
21546                node_tiebreaker: 0,
21547            })
21548        );
21549    }
21550
21551    #[test]
21552    fn new_writes_always_have_some_commit_ts() {
21553        let dir = tempfile::tempdir().unwrap();
21554        let db = Database::create(dir.path()).unwrap();
21555        db.create_table("t", int_pk_schema()).unwrap();
21556        let mut txn = db.begin();
21557        let state = txn.state_handle();
21558        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
21559        let epoch = txn.commit().unwrap();
21560        let crate::txn::TransactionState::Committed(receipt) = state.state() else {
21561            panic!("expected Committed receipt");
21562        };
21563        let handle = db.table("t").unwrap();
21564        let table = handle.lock();
21565        // Product snapshots are HLC-pinned; epoch-only Snapshot::at hides
21566        // HLC-stamped versions by design (no dual authority).
21567        let (snap, _g) = db.snapshot();
21568        let rows = table.visible_rows(snap).expect("visible rows");
21569        assert_eq!(
21570            rows.len(),
21571            1,
21572            "committed put must be visible under HLC snapshot"
21573        );
21574        assert_eq!(rows[0].commit_ts, Some(receipt.commit_ts));
21575        assert_eq!(db.commit_ts_for_epoch(epoch), Some(receipt.commit_ts));
21576    }
21577
21578    #[test]
21579    fn same_transaction_identical_hlc_on_apply() {
21580        use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21581
21582        let dir = tempfile::tempdir().unwrap();
21583        let db = Database::create_cluster_replica(
21584            dir.path(),
21585            ClusterId::from_bytes([1; 16]),
21586            NodeId::from_bytes([2; 16]),
21587            DatabaseId::from_bytes([3; 16]),
21588        )
21589        .unwrap();
21590        db.apply_replicated_catalog_command(&crate::catalog_cmds::CatalogCommandRecord {
21591            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21592            catalog_version: 1,
21593            command: crate::catalog_cmds::CatalogCommand::CreateTable {
21594                name: "t".into(),
21595                schema: int_pk_schema(),
21596                created_epoch: 1,
21597            },
21598        })
21599        .unwrap();
21600        let table_id = db.table_id("t").unwrap();
21601        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
21602            physical_micros: 42_000,
21603            logical: 3,
21604            node_tiebreaker: 9,
21605        };
21606        // Same decision HLC applied twice (two participants / two apply
21607        // calls) must stamp identical commit_ts on every row version.
21608        let staged = vec![StagedTxnWrite::Put {
21609            table_id,
21610            rows: bincode::serialize(&vec![
21611                Row::new(crate::RowId(1), Epoch(0)).with_column(1, Value::Int64(1))
21612            ])
21613            .unwrap(),
21614        }
21615        .encode()
21616        .unwrap()];
21617        assert!(db
21618            .apply_committed_transaction(1 << 63, commit_ts, &staged)
21619            .unwrap());
21620        let staged2 = vec![StagedTxnWrite::Put {
21621            table_id,
21622            rows: bincode::serialize(&vec![
21623                Row::new(crate::RowId(2), Epoch(0)).with_column(1, Value::Int64(2))
21624            ])
21625            .unwrap(),
21626        }
21627        .encode()
21628        .unwrap()];
21629        assert!(db
21630            .apply_committed_transaction((1 << 63) + 1, commit_ts, &staged2)
21631            .unwrap());
21632        let handle = db.table("t").unwrap();
21633        let table = handle.lock();
21634        let row1 = table
21635            .get(crate::RowId(1), Snapshot::unbounded())
21636            .expect("applied row 1");
21637        let row2 = table
21638            .get(crate::RowId(2), Snapshot::unbounded())
21639            .expect("applied row 2");
21640        // The durable WAL `Put` payload does not carry `Row::commit_ts`
21641        // (0.63.1 bincode layout); rows are restamped from the txn's
21642        // `Op::CommitTimestamp` ledger record, which carries physical micros
21643        // only. Both applies must observe that identical recovery form.
21644        let recovery_form = mongreldb_types::hlc::HlcTimestamp {
21645            physical_micros: commit_ts.physical_micros,
21646            logical: 0,
21647            node_tiebreaker: 0,
21648        };
21649        assert_eq!(row1.commit_ts, Some(recovery_form));
21650        assert_eq!(row2.commit_ts, Some(recovery_form));
21651        assert_eq!(row1.commit_ts, row2.commit_ts);
21652        drop(table);
21653        // Latest applied epoch's ledger entry matches the shared decision HLC
21654        // physical component (logical/tiebreaker may be zero on recovery form).
21655        let epoch = db.visible_epoch();
21656        assert_eq!(
21657            db.commit_ts_for_epoch(epoch).map(|ts| ts.physical_micros),
21658            Some(commit_ts.physical_micros)
21659        );
21660    }
21661
21662    #[test]
21663    fn snapshot_hlc_hides_later_commit_ts_even_if_epoch_higher() {
21664        use mongreldb_types::hlc::HlcTimestamp;
21665        let early = HlcTimestamp {
21666            physical_micros: 100,
21667            logical: 0,
21668            node_tiebreaker: 1,
21669        };
21670        let late = HlcTimestamp {
21671            physical_micros: 200,
21672            logical: 0,
21673            node_tiebreaker: 1,
21674        };
21675        // Snapshot at early HLC with a high epoch budget still hides a later HLC.
21676        let snap = Snapshot::at_hlc(Epoch(99), early);
21677        assert!(!snap.observes_version(Epoch(1), Some(late)));
21678        assert!(snap.observes_version(Epoch(1), Some(early)));
21679        // Live Database snapshots are HLC-pinned.
21680        let dir = tempfile::tempdir().unwrap();
21681        let db = Database::create(dir.path()).unwrap();
21682        let (snap, _g) = db.snapshot();
21683        assert_ne!(
21684            snap.commit_ts,
21685            HlcTimestamp::ZERO,
21686            "Database::snapshot must pin live HLC via at_hlc"
21687        );
21688    }
21689
21690    #[test]
21691    fn hlc_stamped_row_visible_at_hlc_snapshot_not_epoch_only() {
21692        let dir = tempfile::tempdir().unwrap();
21693        let db = Database::create(dir.path()).unwrap();
21694        db.create_table("t", int_pk_schema()).unwrap();
21695        let (_epoch, commit_ts) = commit_one(&db);
21696        assert_ne!(commit_ts, mongreldb_types::hlc::HlcTimestamp::ZERO);
21697
21698        let handle = db.table("t").unwrap();
21699        let table = handle.lock();
21700        // (a) HLC-stamped row visible at an HLC-pinned snapshot.
21701        let hlc_snap = Snapshot::at_hlc(Epoch(u64::MAX), commit_ts);
21702        let rows = table.visible_rows(hlc_snap).expect("visible");
21703        assert_eq!(rows.len(), 1);
21704        assert_eq!(rows[0].commit_ts, Some(commit_ts));
21705        assert!(table.get(rows[0].row_id, hlc_snap).is_some());
21706
21707        // (b) Epoch-only snapshot still sees HLC-stamped rows by epoch (dual-model).
21708        let legacy = Snapshot::at(Epoch(u64::MAX));
21709        assert!(!legacy.uses_hlc_authority());
21710        assert_eq!(
21711            table.visible_rows(legacy).expect("visible").len(),
21712            1,
21713            "epoch pin sees HLC-stamped rows by epoch during dual-model migration"
21714        );
21715        assert!(table.get(rows[0].row_id, legacy).is_some());
21716    }
21717
21718    #[test]
21719    fn hlc_gc_floor_reports_named_sources() {
21720        let dir = tempfile::tempdir().unwrap();
21721        let db = Database::create(dir.path()).unwrap();
21722        db.create_table("t", int_pk_schema()).unwrap();
21723        let (epoch, commit_ts) = commit_one(&db);
21724
21725        // No pins: every HLC source is ZERO.
21726        let empty = db.hlc_gc_floor();
21727        assert_eq!(empty.floor(), mongreldb_types::hlc::HlcTimestamp::ZERO);
21728        assert_eq!(empty.sources().len(), 6);
21729
21730        // Pin via product snapshot (transaction source, epoch-backed).
21731        let (_snap, guard) = db.snapshot();
21732        let with_pin = db.hlc_gc_floor();
21733        // Projection succeeds only when the ledger has a stamp for the pin epoch.
21734        let projected = db.commit_ts_for_epoch(epoch);
21735        if let Some(ts) = projected {
21736            // Snapshot pins the *visible* watermark, which should match the commit.
21737            assert_eq!(with_pin.transaction_snapshot, ts);
21738            assert_eq!(with_pin.floor(), ts);
21739            assert_eq!(ts.physical_micros, commit_ts.physical_micros);
21740        } else {
21741            assert_eq!(
21742                with_pin.transaction_snapshot,
21743                mongreldb_types::hlc::HlcTimestamp::ZERO
21744            );
21745        }
21746        drop(guard);
21747    }
21748}
21749
21750#[cfg(test)]
21751mod stage2e_storage_mode_tests {
21752    use super::*;
21753    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21754    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
21755    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21756
21757    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
21758        (
21759            ClusterId::from_bytes([seed; 16]),
21760            NodeId::from_bytes([seed + 1; 16]),
21761            DatabaseId::from_bytes([seed + 2; 16]),
21762        )
21763    }
21764
21765    fn marker(root: &Path) -> Option<StorageMode> {
21766        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
21767        crate::storage_mode::read(&durable).unwrap()
21768    }
21769
21770    fn simple_schema() -> Schema {
21771        Schema {
21772            columns: vec![ColumnDef {
21773                id: 1,
21774                name: "id".into(),
21775                ty: TypeId::Int64,
21776                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21777                default_value: None,
21778                embedding_source: None,
21779            }],
21780            ..Schema::default()
21781        }
21782    }
21783
21784    #[test]
21785    fn standalone_create_writes_marker_and_reopens() {
21786        let dir = tempfile::tempdir().unwrap();
21787        let root = dir.path().join("db");
21788        let db = Database::create(&root).unwrap();
21789        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21790        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
21791        drop(db);
21792        let db = Database::open(&root).unwrap();
21793        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21794        drop(db);
21795    }
21796
21797    #[test]
21798    fn legacy_database_without_marker_opens_and_gains_marker() {
21799        let dir = tempfile::tempdir().unwrap();
21800        let root = dir.path().join("db");
21801        let db = Database::create(&root).unwrap();
21802        drop(db);
21803        // Simulate a pre-marker database.
21804        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
21805        assert_eq!(marker(&root), None);
21806        let db = Database::open(&root).unwrap();
21807        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21808        drop(db);
21809    }
21810
21811    #[test]
21812    fn server_owned_standalone_opens_embedded() {
21813        let dir = tempfile::tempdir().unwrap();
21814        let root = dir.path().join("db");
21815        let db = Database::create(&root).unwrap();
21816        drop(db);
21817        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
21818        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
21819        let db = Database::open(&root).unwrap();
21820        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
21821        drop(db);
21822    }
21823
21824    #[test]
21825    fn cluster_replica_is_rejected_by_normal_opens() {
21826        let dir = tempfile::tempdir().unwrap();
21827        let root = dir.path().join("db");
21828        let (cluster_id, node_id, database_id) = identity(10);
21829        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21830        assert_eq!(
21831            marker(&root),
21832            Some(StorageMode::ClusterReplica {
21833                cluster_id,
21834                node_id,
21835                database_id,
21836            })
21837        );
21838        drop(db);
21839
21840        let error = Database::open(&root).unwrap_err();
21841        let message = error.to_string();
21842        assert!(
21843            matches!(error, MongrelError::InvalidArgument(_)),
21844            "unexpected error: {message}"
21845        );
21846        assert!(message.contains("cluster node runtime"), "{message}");
21847        assert!(message.contains(&cluster_id.to_hex()), "{message}");
21848        assert!(message.contains(&node_id.to_hex()), "{message}");
21849        assert!(message.contains(&database_id.to_hex()), "{message}");
21850
21851        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
21852        assert!(error.to_string().contains("cluster node runtime"));
21853        // The rejected opens never disturbed the marker.
21854        assert_eq!(
21855            marker(&root),
21856            Some(StorageMode::ClusterReplica {
21857                cluster_id,
21858                node_id,
21859                database_id,
21860            })
21861        );
21862    }
21863
21864    #[test]
21865    fn offline_validation_opens_cluster_replica_read_only() {
21866        let dir = tempfile::tempdir().unwrap();
21867        let root = dir.path().join("db");
21868        let (cluster_id, node_id, database_id) = identity(20);
21869        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21870        drop(db);
21871
21872        let options = OpenOptions::default().with_offline_validation(true);
21873        let db = Database::open_with_options(&root, options).unwrap();
21874        assert!(db.is_read_only_replica());
21875        let error = db.create_table("t", simple_schema()).unwrap_err();
21876        assert!(matches!(error, MongrelError::ReadOnlyReplica));
21877        drop(db);
21878        // Offline validation leaves the marker exactly as found.
21879        assert_eq!(
21880            marker(&root),
21881            Some(StorageMode::ClusterReplica {
21882                cluster_id,
21883                node_id,
21884                database_id,
21885            })
21886        );
21887    }
21888
21889    #[test]
21890    fn cluster_runtime_open_requires_exact_identity() {
21891        let dir = tempfile::tempdir().unwrap();
21892        let root = dir.path().join("db");
21893        let (cluster_id, node_id, database_id) = identity(30);
21894        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21895        drop(db);
21896
21897        // A non-ClusterReplica expectation is a caller error.
21898        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
21899        assert!(matches!(error, MongrelError::InvalidArgument(_)));
21900        // Wrong database identity fails closed.
21901        let wrong = StorageMode::ClusterReplica {
21902            cluster_id,
21903            node_id,
21904            database_id: DatabaseId::from_bytes([99; 16]),
21905        };
21906        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
21907        assert!(error.to_string().contains("identity mismatch"), "{error}");
21908        // A legacy database without a marker is not a cluster replica.
21909        let legacy = dir.path().join("legacy");
21910        let legacy_db = Database::create(&legacy).unwrap();
21911        drop(legacy_db);
21912        let expected = StorageMode::ClusterReplica {
21913            cluster_id,
21914            node_id,
21915            database_id,
21916        };
21917        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
21918        assert!(error.to_string().contains("identity mismatch"), "{error}");
21919
21920        // The matching identity opens; user writes are rejected (writes
21921        // arrive through the replicated apply path only).
21922        let db = Database::open_cluster_replica(&root, &expected).unwrap();
21923        assert!(db.is_read_only_replica());
21924        let error = db.create_table("t", simple_schema()).unwrap_err();
21925        assert!(matches!(error, MongrelError::ReadOnlyReplica));
21926        drop(db);
21927    }
21928}
21929
21930#[cfg(test)]
21931mod stage2e_replicated_apply_tests {
21932    use super::*;
21933    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
21934    use crate::memtable::{Row, Value};
21935    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21936    use crate::wal::{Op, Record};
21937    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21938    use std::sync::Arc;
21939
21940    fn ids() -> (ClusterId, NodeId, DatabaseId) {
21941        (
21942            ClusterId::from_bytes([1; 16]),
21943            NodeId::from_bytes([2; 16]),
21944            DatabaseId::from_bytes([3; 16]),
21945        )
21946    }
21947
21948    fn expected_mode() -> crate::storage_mode::StorageMode {
21949        let (cluster_id, node_id, database_id) = ids();
21950        crate::storage_mode::StorageMode::ClusterReplica {
21951            cluster_id,
21952            node_id,
21953            database_id,
21954        }
21955    }
21956
21957    fn simple_schema() -> Schema {
21958        Schema {
21959            columns: vec![ColumnDef {
21960                id: 1,
21961                name: "id".into(),
21962                ty: TypeId::Int64,
21963                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21964                default_value: None,
21965                embedding_source: None,
21966            }],
21967            ..Schema::default()
21968        }
21969    }
21970
21971    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
21972        CatalogCommandRecord {
21973            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21974            catalog_version,
21975            command: CatalogCommand::CreateTable {
21976                name: name.to_string(),
21977                schema: simple_schema(),
21978                created_epoch: 1,
21979            },
21980        }
21981    }
21982
21983    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
21984        let rows: Vec<Row> = values
21985            .iter()
21986            .map(|value| {
21987                // Distinct row ids per value so batches never overwrite each
21988                // other's MVCC versions.
21989                Row::new(crate::RowId(*value as u64), Epoch(epoch))
21990                    .with_column(1, Value::Int64(*value))
21991            })
21992            .collect();
21993        vec![
21994            Record::new(
21995                Epoch(0),
21996                txn_id,
21997                Op::Put {
21998                    table_id,
21999                    rows: bincode::serialize(&rows).unwrap(),
22000                },
22001            ),
22002            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22003            Record::new(
22004                Epoch(0),
22005                txn_id,
22006                Op::TxnCommit {
22007                    epoch,
22008                    added_runs: Vec::new(),
22009                },
22010            ),
22011        ]
22012    }
22013
22014    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22015        let handle = db.table(table).unwrap();
22016        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22017        let snap = db.visible_snapshot();
22018        let rows = handle.lock().visible_rows(snap).unwrap();
22019        let mut values: Vec<i64> = rows
22020            .iter()
22021            .map(|row| match row.columns.get(&1) {
22022                Some(Value::Int64(value)) => *value,
22023                other => panic!("unexpected column: {other:?}"),
22024            })
22025            .collect();
22026        values.sort_unstable();
22027        values
22028    }
22029
22030    #[test]
22031    fn catalog_command_mounts_table_and_replays_as_noop() {
22032        let dir = tempfile::tempdir().unwrap();
22033        let (cluster_id, node_id, database_id) = ids();
22034        let db =
22035            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22036
22037        let record = create_table_record("items", 1);
22038        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22039        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
22040        assert_eq!(db.table_names(), vec!["items".to_string()]);
22041        assert_eq!(db.catalog_version(), 1);
22042
22043        // Idempotent replay of the same record.
22044        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22045        assert!(matches!(delta, CatalogDelta::NoOp));
22046        assert_eq!(db.table_names().len(), 1);
22047        drop(db);
22048
22049        // The command was checkpointed: the table survives reopen.
22050        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22051        assert_eq!(db.table_names(), vec!["items".to_string()]);
22052        assert_eq!(db.catalog_version(), 1);
22053    }
22054
22055    #[test]
22056    fn records_apply_rows_and_skip_replays_across_restart() {
22057        let dir = tempfile::tempdir().unwrap();
22058        let (cluster_id, node_id, database_id) = ids();
22059        let db =
22060            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22061        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22062            .unwrap();
22063
22064        let records = put_records(1, 0, 2, &[10, 20, 30]);
22065        assert!(db.apply_replicated_records(&records).unwrap());
22066        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22067        assert_eq!(db.visible_epoch(), Epoch(2));
22068
22069        // Crash-window redelivery of the same committed transaction is a
22070        // side-effect-free replay.
22071        assert!(!db.apply_replicated_records(&records).unwrap());
22072        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22073
22074        // A later transaction at a higher epoch still applies.
22075        let later = put_records(2, 0, 3, &[40]);
22076        assert!(db.apply_replicated_records(&later).unwrap());
22077        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22078        let db = Arc::new(db);
22079        db.shutdown().unwrap();
22080
22081        // Restart: the local WAL replays the applied rows, and the state
22082        // machine's redelivery is recognized as a replay — no double-apply.
22083        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22084        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22085        assert!(!db.apply_replicated_records(&later).unwrap());
22086        assert!(!db.apply_replicated_records(&records).unwrap());
22087        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22088    }
22089
22090    #[test]
22091    fn spilled_run_commits_fail_closed_this_wave() {
22092        let dir = tempfile::tempdir().unwrap();
22093        let (cluster_id, node_id, database_id) = ids();
22094        let db =
22095            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22096        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22097            .unwrap();
22098        let mut records = put_records(1, 0, 2, &[10]);
22099        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22100            panic!("put_records ends in TxnCommit");
22101        };
22102        added_runs.push(crate::wal::AddedRun {
22103            table_id: 0,
22104            run_id: 7,
22105            row_count: 1,
22106            level: 0,
22107            min_row_id: 1,
22108            max_row_id: 1,
22109            content_hash: [0; 32],
22110        });
22111        let error = db.apply_replicated_records(&records).unwrap_err();
22112        assert!(
22113            error.to_string().contains("spilled-run"),
22114            "unexpected error: {error}"
22115        );
22116        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22117    }
22118
22119    #[test]
22120    fn records_without_commit_marker_fail_closed() {
22121        let dir = tempfile::tempdir().unwrap();
22122        let (cluster_id, node_id, database_id) = ids();
22123        let db =
22124            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22125        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22126            .unwrap();
22127        let mut records = put_records(1, 0, 2, &[10]);
22128        records.pop(); // strip the TxnCommit
22129        let error = db.apply_replicated_records(&records).unwrap_err();
22130        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22131        assert!(db.apply_replicated_records(&[]).is_err());
22132        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22133    }
22134}
22135
22136#[cfg(test)]
22137mod stage2c_spill_translation_tests {
22138    use super::*;
22139    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
22140    use crate::memtable::{Row, Value};
22141    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
22142    use crate::wal::{Op, Record};
22143    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
22144
22145    fn simple_schema() -> Schema {
22146        Schema {
22147            columns: vec![ColumnDef {
22148                id: 1,
22149                name: "id".into(),
22150                ty: TypeId::Int64,
22151                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22152                default_value: None,
22153                embedding_source: None,
22154            }],
22155            ..Schema::default()
22156        }
22157    }
22158
22159    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22160        CatalogCommandRecord {
22161            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22162            catalog_version,
22163            command: CatalogCommand::CreateTable {
22164                name: name.to_string(),
22165                schema: simple_schema(),
22166                created_epoch: 1,
22167            },
22168        }
22169    }
22170
22171    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22172        let handle = db.table(table).unwrap();
22173        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22174        let snap = db.visible_snapshot();
22175        let rows = handle.lock().visible_rows(snap).unwrap();
22176        let mut values: Vec<i64> = rows
22177            .iter()
22178            .map(|row| match row.columns.get(&1) {
22179                Some(Value::Int64(value)) => *value,
22180                other => panic!("unexpected column: {other:?}"),
22181            })
22182            .collect();
22183        values.sort_unstable();
22184        values
22185    }
22186
22187    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22188        let rows: Vec<Row> = values
22189            .iter()
22190            .map(|value| {
22191                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22192                    .with_column(1, Value::Int64(*value))
22193            })
22194            .collect();
22195        vec![
22196            Record::new(
22197                Epoch(0),
22198                txn_id,
22199                Op::Put {
22200                    table_id,
22201                    rows: bincode::serialize(&rows).unwrap(),
22202                },
22203            ),
22204            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22205            Record::new(
22206                Epoch(0),
22207                txn_id,
22208                Op::TxnCommit {
22209                    epoch,
22210                    added_runs: Vec::new(),
22211                },
22212            ),
22213        ]
22214    }
22215
22216    fn added_run(
22217        table_id: u64,
22218        row_count: u64,
22219        min_row_id: u64,
22220        max_row_id: u64,
22221    ) -> crate::wal::AddedRun {
22222        crate::wal::AddedRun {
22223            table_id,
22224            run_id: 7,
22225            row_count,
22226            level: 0,
22227            min_row_id,
22228            max_row_id,
22229            content_hash: [0; 32],
22230        }
22231    }
22232
22233    /// Replays the shared WAL of `db` and returns every record of the one
22234    /// transaction whose commit marker links spilled runs.
22235    fn spilled_commit_records(db: &Database) -> Vec<Record> {
22236        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
22237        let txn_id = records
22238            .iter()
22239            .find_map(|record| match &record.op {
22240                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
22241                _ => None,
22242            })
22243            .expect("a spilled commit is present in the WAL");
22244        records
22245            .into_iter()
22246            .filter(|record| record.txn_id == txn_id)
22247            .collect()
22248    }
22249
22250    #[test]
22251    fn non_spilled_records_translate_byte_identical() {
22252        let records = put_records(1, 0, 2, &[10, 20, 30]);
22253        let translated = translate_records_for_replication(&records).unwrap();
22254        assert_eq!(
22255            bincode::serialize(&translated).unwrap(),
22256            bincode::serialize(&records).unwrap(),
22257            "a commit without spill links must pass through byte-identical"
22258        );
22259    }
22260
22261    #[test]
22262    fn translation_rejects_uncovered_or_malformed_spills() {
22263        // added_runs with no logical spill records at all: rejected.
22264        let mut records = put_records(1, 0, 2, &[10]);
22265        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22266            panic!("put_records ends in TxnCommit");
22267        };
22268        added_runs.push(added_run(0, 1, 10, 10));
22269        let error = translate_records_for_replication(&records).unwrap_err();
22270        assert!(
22271            error.to_string().contains("no logical row records"),
22272            "unexpected error: {error}"
22273        );
22274
22275        // Coverage present but short of the linked row count: rejected.
22276        let mut records = put_records(1, 0, 2, &[10]);
22277        let spilled: Vec<Row> = (0..3_u64)
22278            .map(|value| {
22279                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
22280            })
22281            .collect();
22282        records.insert(
22283            0,
22284            Record::new(
22285                Epoch(0),
22286                1,
22287                Op::SpilledRows {
22288                    table_id: 0,
22289                    rows: bincode::serialize(&spilled).unwrap(),
22290                },
22291            ),
22292        );
22293        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22294            panic!("put_records ends in TxnCommit");
22295        };
22296        added_runs.push(added_run(0, 4, 0, 3));
22297        let error = translate_records_for_replication(&records).unwrap_err();
22298        assert!(
22299            error.to_string().contains("cover 3 rows"),
22300            "unexpected error: {error}"
22301        );
22302
22303        // An undecodable spill payload: rejected at propose time, never at apply.
22304        let mut records = put_records(1, 0, 2, &[10]);
22305        records.insert(
22306            0,
22307            Record::new(
22308                Epoch(0),
22309                1,
22310                Op::SpilledRows {
22311                    table_id: 0,
22312                    rows: vec![0xFF, 0x01, 0x02],
22313                },
22314            ),
22315        );
22316        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22317            panic!("put_records ends in TxnCommit");
22318        };
22319        added_runs.push(added_run(0, 1, 0, 0));
22320        assert!(translate_records_for_replication(&records).is_err());
22321
22322        // Structural violations mirror the apply-side contract.
22323        assert!(translate_records_for_replication(&[]).is_err());
22324        let mut mixed = put_records(1, 0, 2, &[10]);
22325        mixed[0].txn_id = 99;
22326        assert!(translate_records_for_replication(&mixed).is_err());
22327        let mut no_commit = put_records(1, 0, 2, &[10]);
22328        no_commit.pop();
22329        assert!(translate_records_for_replication(&no_commit).is_err());
22330    }
22331
22332    #[test]
22333    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
22334        // A real standalone commit that spills (spec section 8.5).
22335        let leader_dir = tempfile::tempdir().unwrap();
22336        let leader = Database::create(leader_dir.path()).unwrap();
22337        leader.create_table("t", simple_schema()).unwrap();
22338        leader.set_spill_threshold(1);
22339        let table_id = leader.table_id("t").unwrap();
22340        let values: Vec<i64> = (0..60).collect();
22341        leader
22342            .transaction(|txn| {
22343                for value in &values {
22344                    txn.put("t", vec![(1, Value::Int64(*value))])?;
22345                }
22346                Ok(())
22347            })
22348            .unwrap();
22349        assert_eq!(visible_ids(&leader, "t"), values);
22350
22351        // The leader's own WAL keeps the spill shape: SpilledRows records
22352        // plus an added_runs commit marker.
22353        let records = spilled_commit_records(&leader);
22354        assert!(records
22355            .iter()
22356            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22357        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
22358            panic!("a commit sequence ends in TxnCommit");
22359        };
22360        assert!(!added_runs.is_empty());
22361        let commit_epoch = *epoch;
22362
22363        // Translation strips every run reference and keeps the rows as
22364        // logical puts; the input sequence is untouched.
22365        let translated = translate_records_for_replication(&records).unwrap();
22366        assert!(records
22367            .iter()
22368            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22369        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
22370            panic!("a commit sequence ends in TxnCommit");
22371        };
22372        assert!(!added_runs.is_empty(), "input records must be unchanged");
22373        assert_eq!(translated.len(), records.len());
22374        assert!(translated
22375            .iter()
22376            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
22377        assert!(translated
22378            .iter()
22379            .any(|record| matches!(record.op, Op::Put { .. })));
22380        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
22381            panic!("a commit sequence ends in TxnCommit");
22382        };
22383        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
22384        assert_eq!(*epoch, commit_epoch);
22385
22386        // The translated payload applies on a replica with identical rows.
22387        let replica_dir = tempfile::tempdir().unwrap();
22388        let replica = Database::create_cluster_replica(
22389            replica_dir.path(),
22390            ClusterId::from_bytes([1; 16]),
22391            NodeId::from_bytes([2; 16]),
22392            DatabaseId::from_bytes([3; 16]),
22393        )
22394        .unwrap();
22395        replica
22396            .apply_replicated_catalog_command(&create_table_record("t", 1))
22397            .unwrap();
22398        assert_eq!(replica.table_id("t").unwrap(), table_id);
22399        assert!(replica.apply_replicated_records(&translated).unwrap());
22400        assert_eq!(visible_ids(&replica, "t"), values);
22401        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
22402
22403        // Standalone behavior is unchanged: the leader still recovers its
22404        // spilled commit by linking the run file.
22405        drop(leader);
22406        let leader = Database::open(leader_dir.path()).unwrap();
22407        assert_eq!(visible_ids(&leader, "t"), values);
22408    }
22409
22410    #[test]
22411    fn staged_txn_writes_validate_and_apply() {
22412        let dir = tempfile::tempdir().unwrap();
22413        let db = Database::create_cluster_replica(
22414            dir.path(),
22415            ClusterId::from_bytes([1; 16]),
22416            NodeId::from_bytes([2; 16]),
22417            DatabaseId::from_bytes([3; 16]),
22418        )
22419        .unwrap();
22420        db.apply_replicated_catalog_command(&create_table_record("t", 1))
22421            .unwrap();
22422        let table_id = db.table_id("t").unwrap();
22423
22424        // Malformed payloads and unmounted tables are rejected at prepare.
22425        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
22426        let unknown_table = StagedTxnWrite::Put {
22427            table_id: 99,
22428            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
22429        }
22430        .encode()
22431        .unwrap();
22432        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
22433        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
22434            .iter()
22435            .map(|value| {
22436                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
22437                    .with_column(1, Value::Int64(*value))];
22438                StagedTxnWrite::Put {
22439                    table_id,
22440                    rows: bincode::serialize(&rows).unwrap(),
22441                }
22442                .encode()
22443                .unwrap()
22444            })
22445            .collect();
22446        db.validate_staged_txn_writes(&good).unwrap();
22447
22448        // A committed resolution applies the staged writes; a delete
22449        // resolution removes them; both are replay-safe.
22450        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
22451            physical_micros: 5_000,
22452            logical: 0,
22453            node_tiebreaker: 0,
22454        };
22455        assert!(db
22456            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
22457            .unwrap());
22458        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
22459        let delete = StagedTxnWrite::Delete {
22460            table_id,
22461            row_ids: vec![20],
22462        }
22463        .encode()
22464        .unwrap();
22465        assert!(db
22466            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
22467            .unwrap());
22468        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22469
22470        // Restart: the synthetic WAL transactions replay through the same
22471        // recovery path; the rows are durable.
22472        let db = Arc::new(db);
22473        db.shutdown().unwrap();
22474        let expected = crate::storage_mode::StorageMode::ClusterReplica {
22475            cluster_id: ClusterId::from_bytes([1; 16]),
22476            node_id: NodeId::from_bytes([2; 16]),
22477            database_id: DatabaseId::from_bytes([3; 16]),
22478        };
22479        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
22480        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22481    }
22482}